diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 4c6cf5357af..6b03d5c3256 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -72,6 +72,7 @@ All replies and delegations — including task assignments to other agents — g - Respond promptly to @mentions. Be direct — no preamble. Name what you did, what you found, or what you need. - **If your turn produced anything worth knowing, you MUST publish it.** Use `buzz messages send`. Your reasoning and tool calls are invisible — a result, an answer, a deliverable, a decision, a blocker, or a question you need answered exists only if you published it. Work or an answer that someone asked you for always counts. Ending that kind of turn without a message is a silent failure. +- When completed work produced a shareable file, attach that file to the result message with repeatable `buzz messages send --file --outbox`. The `--outbox` marker makes completed files appear in the human's Outbox; a local path mentioned only in message text does not. - **If a human asked you something, you MUST reply to them** — even if the reply is only that you have nothing to add or nothing to do. Never leave a person waiting on you. - **Otherwise, publishing is optional and silence is usually correct.** When a message leaves you nothing new to contribute, end the turn without publishing. That is a success, not a failure. - **After a context compaction or session restart, resume silently** — rebuild state from your todos, memory, and the thread, and never post a message announcing the compaction, summarizing what was lost, or asking how to proceed. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index a662fccc233..64d136a94b0 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -4477,6 +4477,14 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("buzz messages send ... --content -")); } + #[test] + fn shared_base_prompt_routes_completed_files_to_outbox() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("buzz messages send --file --outbox")); + assert!(prompt.contains("marker makes completed files appear")); + assert!(prompt.contains("local path mentioned only in message text does not")); + } + #[test] fn shared_base_prompt_teaches_repo_context_and_learning_loop() { let prompt = include_str!("base_prompt.md"); diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 71af043bd75..9349cfc1a30 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -37,7 +37,7 @@ pub struct BlobDescriptor { } /// Build an `imeta` tag array from a BlobDescriptor (NIP-92 media metadata). -pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec { +pub fn build_imeta_tag(d: &BlobDescriptor, filename: Option<&str>) -> Vec { let mut tag = vec![ "imeta".to_string(), format!("url {}", d.url), @@ -57,6 +57,9 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec { if let Some(dur) = d.duration { tag.push(format!("duration {dur}")); } + if let Some(filename) = filename.filter(|value| !value.trim().is_empty()) { + tag.push(format!("filename {}", filename.trim())); + } tag } @@ -2307,11 +2310,29 @@ mod retry_policy_tests { #[cfg(test)] mod tests { use super::{ - advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field, - normalize_events, BuzzClient, + advance_query_cursor, build_imeta_tag, create_response_with_id_if_accepted, + extract_relay_response_field, normalize_events, BlobDescriptor, BuzzClient, }; use nostr::{EventBuilder, Keys, Kind, Tag}; + #[test] + fn imeta_preserves_the_attached_filename_for_outbox_display() { + let descriptor = BlobDescriptor { + url: "https://relay.example/media/abc".into(), + sha256: "a".repeat(64), + size: 42, + mime_type: "application/pdf".into(), + uploaded: 1_700_000_000, + dim: None, + blurhash: None, + thumb: None, + duration: None, + }; + + assert!(build_imeta_tag(&descriptor, Some("FINAL_REPORT.pdf")) + .contains(&"filename FINAL_REPORT.pdf".to_string())); + } + #[test] fn normalize_events_preserves_the_complete_signed_event_shape() { let signed_event = EventBuilder::new(Kind::TextNote, "signed content") diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 9f41fbf751c..061345cb887 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -1,5 +1,6 @@ use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection}; -use nostr::PublicKey; +use nostr::{PublicKey, Tag}; +use std::path::Path; use uuid::Uuid; use crate::client::{normalize_events, normalize_write_response, BuzzClient}; @@ -605,6 +606,7 @@ pub struct SendMessageParams { pub reply_to: Option, pub broadcast: bool, pub files: Vec, + pub outbox: bool, pub mentions: Vec, } @@ -655,7 +657,10 @@ pub async fn cmd_send_message( .upload_file(file_path) .await .map_err(|e| CliError::Other(format!("upload failed for {file_path}: {e}")))?; - media_tags.push(crate::client::build_imeta_tag(&desc)); + let filename = Path::new(file_path) + .file_name() + .and_then(|value| value.to_str()); + media_tags.push(crate::client::build_imeta_tag(&desc, filename)); if desc.mime_type.starts_with("video/") { media_content.push_str("\n![video]("); } else { @@ -680,7 +685,7 @@ pub async fn cmd_send_message( let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect(); - let builder = match p.kind { + let mut builder = match p.kind { Some(45001) => { buzz_sdk::build_forum_post(channel_uuid, &final_content, &mention_refs, &media_tags) .map_err(|e| CliError::Other(format!("build_forum_post failed: {e}")))? @@ -713,6 +718,12 @@ pub async fn cmd_send_message( ))) } }; + if p.outbox { + builder = builder.tag( + Tag::parse(["buzz-outbox", "1"]) + .map_err(|error| CliError::Other(format!("build outbox tag failed: {error}")))?, + ); + } let event = client.sign_event(builder)?; let emitted_mentions = event_mention_pubkeys(&event); @@ -916,6 +927,7 @@ pub async fn dispatch( reply_to, broadcast, files, + outbox, mentions, } => { cmd_send_message( @@ -927,6 +939,7 @@ pub async fn dispatch( reply_to, broadcast, files, + outbox, mentions, }, ) diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 5cac8c941e1..323f94332c5 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -371,7 +371,7 @@ buzz agents archived" pub enum MessagesCmd { /// Send a message to a channel #[command( - after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel --content -" + after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n buzz messages send --channel --content \"Completed\" --file ./report.pdf --outbox\n echo \"hello from stdin\" | buzz messages send --channel --content -" )] Send { /// Channel UUID (from 'buzz channels list') @@ -392,6 +392,9 @@ pub enum MessagesCmd { /// Attach file(s) — uploads and includes as imeta tags #[arg(long = "file")] files: Vec, + /// Mark attached files as completed deliverables for the human's Outbox + #[arg(long, default_value_t = false, requires = "files")] + outbox: bool, /// Pubkey to mention (hex or npub; repeatable). Supplying any explicit identity permits unresolved or ambiguous @Name text as presentation-only; uniquely resolved member names still notify. #[arg(long = "mention")] mentions: Vec, @@ -2167,6 +2170,35 @@ mod tests { .is_err()); } + #[test] + fn messages_send_outbox_requires_an_attached_file() { + let channel = "123e4567-e89b-12d3-a456-426614174000"; + assert!(Cli::try_parse_from([ + "buzz", + "messages", + "send", + "--channel", + channel, + "--content", + "Complete", + "--file", + "report.pdf", + "--outbox", + ]) + .is_ok()); + assert!(Cli::try_parse_from([ + "buzz", + "messages", + "send", + "--channel", + channel, + "--content", + "Complete", + "--outbox", + ]) + .is_err()); + } + #[test] fn set_status_clear_rejects_text_and_emoji() { for extra in [["--text", "busy"], ["--emoji", "🎶"]] { diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index be15c75587d..714fc4a16af 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -126,6 +126,7 @@ export default defineConfig({ "**/drafts-screenshots.spec.ts", "**/drafts-all-fix-screenshots.spec.ts", "**/inbox-refactor-screenshots.spec.ts", + "**/outbox.spec.ts", "**/buzz-theme-screenshots.spec.ts", "**/appearance-previews.spec.ts", "**/channel-sort.spec.ts", diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index 7bc94da25d2..cc9c0b3ba43 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -1,6 +1,7 @@ use futures_util::StreamExt; use sha2::{Digest, Sha256}; use tauri::State; +use tauri_plugin_opener::OpenerExt; use crate::app_state::AppState; use crate::commands::clipboard::with_clipboard; @@ -139,6 +140,44 @@ pub async fn download_file( save_bytes_with_dialog(&app, &filename, "All Files", &extensions, &bytes).await } +/// Download a relay-hosted artifact into an isolated temporary directory and +/// open it with the operating system's default application. +/// +/// Outbox uses this instead of a bare webview link so one click reaches the +/// actual artifact without handing authenticated media URLs to an external +/// browser. The same origin, size, and MIME safeguards as `download_file` +/// apply before any bytes are written locally. +#[tauri::command] +pub async fn open_artifact( + url: String, + filename: String, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let relay_base = relay_api_base_url_with_override(&state); + validate_download_url(&url, &relay_base)?; + + let filename = sanitize_filename(&filename); + let bytes = fetch_blob_bytes(&url, &state).await?; + detect_and_validate_mime(&bytes)?; + + let artifact_dir = std::env::temp_dir().join(format!( + "buzz-artifact-{}", + uuid::Uuid::new_v4().simple() + )); + tokio::fs::create_dir_all(&artifact_dir) + .await + .map_err(|error| format!("create artifact directory: {error}"))?; + let artifact_path = artifact_dir.join(filename); + tokio::fs::write(&artifact_path, bytes) + .await + .map_err(|error| format!("write artifact: {error}"))?; + + app.opener() + .open_path(artifact_path.to_string_lossy(), None::<&str>) + .map_err(|error| format!("open artifact: {error}")) +} + /// Fetch relay media bytes for the composer image editor. /// /// The editor composites the image onto a canvas and needs pixel access. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 613040b8095..734a1c7cace 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -670,6 +670,7 @@ pub fn run() { download_image, save_png_data_url, download_file, + open_artifact, fetch_media_bytes, copy_image_to_clipboard, copy_text_to_clipboard, diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 9fc14736c7c..f3e7aceed2c 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -4,6 +4,7 @@ import type { SearchHit } from "@/shared/api/types"; export type AppView = | "home" + | "outbox" | "channel" | "messages" | "agents" @@ -233,6 +234,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/outbox") { + return { + selectedChannelId: null, + selectedView: "outbox", + }; + } + if (pathname === "/agents") { return { selectedChannelId: null, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 468435e15ec..f3bd18e4faf 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -146,6 +146,7 @@ export function AppShell() { goChannel, goHome, goNewMessage, + goOutbox, goProjects, goPulse, goSettings, @@ -880,6 +881,7 @@ export function AppShell() { scopeSearchFocusRequest, ]} onSelectHome={() => void goHome()} + onSelectOutbox={() => void goOutbox()} onSelectProjects={() => void goProjects()} onSelectPulse={() => void goPulse()} onSelectSettings={handleOpenSettings} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index c82c4d96eea..51ad8557bbf 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -82,6 +82,17 @@ export function useAppNavigation() { [commitNavigation], ); + const goOutbox = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/outbox", + }, + behavior, + ), + [commitNavigation], + ); + const goAgents = React.useCallback( (behavior?: NavigationBehavior) => commitNavigation( @@ -465,6 +476,7 @@ export function useAppNavigation() { goNewMessage, goNewWorkflow, goNewWorkflowForChannel, + goOutbox, goProject, goProjects, goPulse, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2bc2c8ddb6d..d89ae95e4da 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as settingsRouteImport } from "./routes/settings"; import { Route as remindersRouteImport } from "./routes/reminders"; import { Route as pulseRouteImport } from "./routes/pulse"; import { Route as projectsRouteImport } from "./routes/projects"; +import { Route as outboxRouteImport } from "./routes/outbox"; import { Route as agentsRouteImport } from "./routes/agents"; import { Route as indexRouteImport } from "./routes/index"; import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId"; @@ -43,6 +44,11 @@ const projectsRoute = projectsRouteImport.update({ path: "/projects", getParentRoute: () => rootRouteImport, } as any); +const outboxRoute = outboxRouteImport.update({ + id: "/outbox", + path: "/outbox", + getParentRoute: () => rootRouteImport, +} as any); const agentsRoute = agentsRouteImport.update({ id: "/agents", path: "/agents", @@ -83,6 +89,7 @@ const channelsDotchannelIdDotpostsDotpostIdRoute = export interface FileRoutesByFullPath { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/outbox": typeof outboxRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -97,6 +104,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/outbox": typeof outboxRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -112,6 +120,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport; "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/outbox": typeof outboxRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -128,6 +137,7 @@ export interface FileRouteTypes { fullPaths: | "/" | "/agents" + | "/outbox" | "/projects" | "/pulse" | "/reminders" @@ -142,6 +152,7 @@ export interface FileRouteTypes { to: | "/" | "/agents" + | "/outbox" | "/projects" | "/pulse" | "/reminders" @@ -156,6 +167,7 @@ export interface FileRouteTypes { | "__root__" | "/" | "/agents" + | "/outbox" | "/projects" | "/pulse" | "/reminders" @@ -171,6 +183,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { indexRoute: typeof indexRoute; agentsRoute: typeof agentsRoute; + outboxRoute: typeof outboxRoute; projectsRoute: typeof projectsRoute; pulseRoute: typeof pulseRoute; remindersRoute: typeof remindersRoute; @@ -220,6 +233,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof projectsRouteImport; parentRoute: typeof rootRouteImport; }; + "/outbox": { + id: "/outbox"; + path: "/outbox"; + fullPath: "/outbox"; + preLoaderRoute: typeof outboxRouteImport; + parentRoute: typeof rootRouteImport; + }; "/agents": { id: "/agents"; path: "/agents"; @@ -275,6 +295,7 @@ declare module "@tanstack/react-router" { const rootRouteChildren: RootRouteChildren = { indexRoute: indexRoute, agentsRoute: agentsRoute, + outboxRoute: outboxRoute, projectsRoute: projectsRoute, pulseRoute: pulseRoute, remindersRoute: remindersRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11a..ba400d8b016 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -2,6 +2,7 @@ import { index, rootRoute, route } from "@tanstack/virtual-file-routes"; export const routes = rootRoute("root.tsx", [ index("index.tsx"), + route("/outbox", "outbox.tsx"), route("/agents", "agents.tsx"), route("/pulse", "pulse.tsx"), route("/reminders", "reminders.tsx"), diff --git a/desktop/src/app/routes/outbox.tsx b/desktop/src/app/routes/outbox.tsx new file mode 100644 index 00000000000..a70229779a7 --- /dev/null +++ b/desktop/src/app/routes/outbox.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { OutboxScreen } from "@/features/outbox/ui/OutboxScreen"; + +export const Route = createFileRoute("/outbox")({ + component: OutboxScreen, +}); diff --git a/desktop/src/features/outbox/lib/artifacts.test.mjs b/desktop/src/features/outbox/lib/artifacts.test.mjs new file mode 100644 index 00000000000..0105785fe05 --- /dev/null +++ b/desktop/src/features/outbox/lib/artifacts.test.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildOutboxArtifacts } from "./artifacts.ts"; + +const AGENT = "a".repeat(64); +const HUMAN = "b".repeat(64); + +function event(overrides = {}) { + return { + id: "c".repeat(64), + pubkey: AGENT, + created_at: 1_700_000_000, + kind: 9, + tags: [ + ["h", "channel-1"], + ["buzz-outbox", "1"], + [ + "imeta", + "url https://relay.example/media/report.pdf", + "m application/pdf", + `x ${"d".repeat(64)}`, + "size 2048", + "filename QUARTERLY_REPORT.pdf", + ], + ], + content: + "The review package is complete.\n\n[QUARTERLY_REPORT.pdf](https://relay.example/media/report.pdf)", + sig: "e".repeat(128), + ...overrides, + }; +} + +test("projects agent attachments into newest-first outbox rows", () => { + const older = event({ id: "1".repeat(64), created_at: 100 }); + const newer = event({ + id: "2".repeat(64), + created_at: 200, + tags: [ + ["h", "channel-2"], + ["buzz-outbox", "1"], + [ + "imeta", + "url https://relay.example/media/mockup.png", + "m image/png", + `x ${"f".repeat(64)}`, + "size 4096", + "filename MOCKUP.png", + ], + ], + }); + + const artifacts = buildOutboxArtifacts([older, newer], new Set([AGENT])); + + assert.deepEqual( + artifacts.map((artifact) => artifact.filename), + ["MOCKUP.png", "QUARTERLY_REPORT.pdf"], + ); + assert.equal(artifacts[0].kind, "image"); + assert.equal(artifacts[1].kind, "document"); + assert.equal(artifacts[1].channelId, "channel-1"); + assert.equal(artifacts[1].sourceSummary, "The review package is complete."); +}); + +test("ignores human attachments, unmarked files, and messages without files", () => { + const humanAttachment = event({ pubkey: HUMAN }); + const agentMessage = event({ tags: [["h", "channel-1"]] }); + const unmarkedAttachment = event({ + id: "4".repeat(64), + tags: event().tags.filter((tag) => tag[0] !== "buzz-outbox"), + }); + + assert.deepEqual( + buildOutboxArtifacts( + [humanAttachment, agentMessage, unmarkedAttachment], + new Set([AGENT]), + ), + [], + ); +}); + +test("falls back safely when optional imeta metadata is absent", () => { + const artifact = buildOutboxArtifacts( + [ + event({ + tags: [ + ["h", "channel-1"], + ["buzz-outbox", "1"], + ["imeta", "url https://relay.example/media/notes.txt"], + ], + }), + ], + new Set([AGENT.toUpperCase()]), + )[0]; + + assert.equal(artifact.filename, "notes.txt"); + assert.equal(artifact.mimeType, "application/octet-stream"); + assert.equal(artifact.size, undefined); +}); diff --git a/desktop/src/features/outbox/lib/artifacts.ts b/desktop/src/features/outbox/lib/artifacts.ts new file mode 100644 index 00000000000..8ac84155639 --- /dev/null +++ b/desktop/src/features/outbox/lib/artifacts.ts @@ -0,0 +1,135 @@ +import { + KIND_FORUM_COMMENT, + KIND_FORUM_POST, + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, +} from "@/shared/constants/kinds"; +import type { RelayEvent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; + +export const OUTBOX_MESSAGE_KINDS = [ + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, +] as const; + +export type OutboxArtifactKind = "document" | "image" | "video"; + +export type OutboxArtifact = { + id: string; + eventId: string; + eventKind: number; + authorPubkey: string; + channelId: string | null; + createdAt: number; + filename: string; + kind: OutboxArtifactKind; + mimeType: string; + sha256: string; + size: number | undefined; + sourceContent: string; + sourceSummary: string; + sourceTags: string[][]; + url: string; +}; + +function isOutboxDelivery(tags: readonly string[][]): boolean { + return tags.some((tag) => tag[0] === "buzz-outbox" && tag[1] === "1"); +} + +function getChannelId(tags: readonly string[][]): string | null { + return tags.find((tag) => tag[0] === "h")?.[1] ?? null; +} + +function fallbackFilename(url: string, index: number): string { + try { + const tail = new URL(url).pathname.split("/").filter(Boolean).at(-1); + if (tail) return decodeURIComponent(tail); + } catch { + // Relative and malformed URLs still receive a useful stable label. + } + return `Artifact ${index + 1}`; +} + +function classifyArtifact(mimeType: string): OutboxArtifactKind { + const normalized = mimeType.toLowerCase(); + if (normalized.startsWith("image/")) return "image"; + if (normalized.startsWith("video/")) return "video"; + return "document"; +} + +function summarizeSource(content: string, attachmentUrls: ReadonlySet) { + const summary = content + .split("\n") + .map((line) => line.trim()) + .filter((line) => { + if (!line) return false; + for (const url of attachmentUrls) { + if (line.includes(`](${url})`)) return false; + } + return true; + }) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + + if (summary.length <= 180) return summary; + return `${summary.slice(0, 177).trimEnd()}…`; +} + +/** + * Projects durable file attachments from known-agent messages into the + * product's Outbox read model. An attachment is the handoff boundary: local + * workspace paths never become discoverable or clickable by accident. + */ +export function buildOutboxArtifacts( + events: readonly RelayEvent[], + knownAgentPubkeys: ReadonlySet, +): OutboxArtifact[] { + const normalizedAgents = new Set( + [...knownAgentPubkeys].map((pubkey) => normalizePubkey(pubkey)), + ); + const artifacts: OutboxArtifact[] = []; + + for (const event of events) { + if (!normalizedAgents.has(normalizePubkey(event.pubkey))) continue; + if (!isOutboxDelivery(event.tags)) continue; + + const imetaEntries = [...parseImetaTags(event.tags).values()]; + if (imetaEntries.length === 0) continue; + + const attachmentUrls = new Set(imetaEntries.map((entry) => entry.url)); + const sourceSummary = summarizeSource(event.content, attachmentUrls); + + imetaEntries.forEach((entry, index) => { + const mimeType = entry.m || "application/octet-stream"; + artifacts.push({ + id: `${event.id}:${index}`, + eventId: event.id, + eventKind: event.kind, + authorPubkey: normalizePubkey(event.pubkey), + channelId: getChannelId(event.tags), + createdAt: event.created_at, + filename: entry.filename?.trim() || fallbackFilename(entry.url, index), + kind: classifyArtifact(mimeType), + mimeType, + sha256: entry.x || "", + size: + Number.isFinite(entry.size) && entry.size >= 0 + ? entry.size + : undefined, + sourceContent: event.content, + sourceSummary, + sourceTags: event.tags, + url: entry.url, + }); + }); + } + + return artifacts.sort( + (left, right) => + right.createdAt - left.createdAt || left.id.localeCompare(right.id), + ); +} diff --git a/desktop/src/features/outbox/ui/OutboxScreen.tsx b/desktop/src/features/outbox/ui/OutboxScreen.tsx new file mode 100644 index 00000000000..1b2093cb3f0 --- /dev/null +++ b/desktop/src/features/outbox/ui/OutboxScreen.tsx @@ -0,0 +1,319 @@ +import * as React from "react"; +import { + ExternalLink, + FileOutput, + FileText, + Film, + Image as ImageIcon, + LoaderCircle, + RefreshCcw, +} from "lucide-react"; +import { toast } from "sonner"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { getThreadReference } from "@/features/messages/lib/threading"; +import type { OutboxArtifact } from "@/features/outbox/lib/artifacts"; +import { useOutboxArtifacts } from "@/features/outbox/useOutboxArtifacts"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import { openArtifactFile } from "@/shared/api/tauriMedia"; +import type { SearchHit } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Skeleton } from "@/shared/ui/skeleton"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +function formatArtifactSize(bytes: number | undefined) { + if (bytes === undefined || bytes < 0 || !Number.isFinite(bytes)) return null; + if (bytes < 1_024) return `${bytes} B`; + if (bytes < 1_048_576) return `${(bytes / 1_024).toFixed(1)} KB`; + return `${(bytes / 1_048_576).toFixed(1)} MB`; +} + +function formatArtifactTime(createdAt: number) { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(createdAt * 1_000)); +} + +function ArtifactIcon({ artifact }: { artifact: OutboxArtifact }) { + const iconClassName = "h-5 w-5"; + if (artifact.kind === "image") { + return ; + } + if (artifact.kind === "video") { + return ; + } + return ; +} + +function OutboxLoadingState() { + return ( +
+ {[0, 1, 2].map((index) => ( +
+ +
+ + +
+ +
+ ))} +
+ ); +} + +export function OutboxScreen() { + const artifactsQuery = useOutboxArtifacts(); + const artifacts = artifactsQuery.data ?? []; + const channels = useChannelsQuery().data ?? []; + const channelById = React.useMemo( + () => new Map(channels.map((channel) => [channel.id, channel])), + [channels], + ); + const authorPubkeys = React.useMemo( + () => [...new Set(artifacts.map((artifact) => artifact.authorPubkey))], + [artifacts], + ); + const profiles = useUsersBatchQuery(authorPubkeys, { + enabled: authorPubkeys.length > 0, + }).data?.profiles; + const { openSearchHit } = useAppNavigation(); + const [openingArtifactId, setOpeningArtifactId] = React.useState< + string | null + >(null); + + const handleOpenArtifact = React.useCallback( + async (artifact: OutboxArtifact) => { + setOpeningArtifactId(artifact.id); + try { + await openArtifactFile(artifact.url, artifact.filename); + } catch (error) { + toast.error("Could not open artifact", { + description: + error instanceof Error ? error.message : "Artifact open failed.", + }); + } finally { + setOpeningArtifactId((current) => + current === artifact.id ? null : current, + ); + } + }, + [], + ); + + const handleOpenSource = React.useCallback( + (artifact: OutboxArtifact) => { + if (!artifact.channelId) return; + const channel = channelById.get(artifact.channelId); + const threadRootId = getThreadReference(artifact.sourceTags).rootId; + const hit: SearchHit = { + eventId: artifact.eventId, + content: artifact.sourceContent, + kind: artifact.eventKind, + pubkey: artifact.authorPubkey, + channelId: artifact.channelId, + channelName: channel?.name ?? null, + createdAt: artifact.createdAt, + score: 0, + threadRootId, + }; + void openSearchHit(hit); + }, + [channelById, openSearchHit], + ); + + return ( +
+
+
+
+
+ + + Delivered work + +
+

+ Outbox +

+

+ Files your agents finished and attached, newest first. +

+
+ +
+
+ +
+ {artifactsQuery.isLoading ? : null} + + {!artifactsQuery.isLoading && artifactsQuery.isError ? ( +
+
+ +
+

Outbox could not load

+

+ Check the community connection, then try again. +

+ +
+ ) : null} + + {!artifactsQuery.isLoading && + !artifactsQuery.isError && + artifacts.length === 0 ? ( +
+
+ +
+

Your Outbox is ready

+

+ When an agent attaches a completed file to its result message, it + will appear here automatically. No separate deliverables channel + or folder hunt. +

+
+ ) : null} + + {!artifactsQuery.isLoading && + !artifactsQuery.isError && + artifacts.length > 0 ? ( +
+ {artifacts.map((artifact) => { + const profile = profiles?.[artifact.authorPubkey]; + const agentLabel = resolveUserLabel({ + pubkey: artifact.authorPubkey, + profiles, + }); + const channel = artifact.channelId + ? channelById.get(artifact.channelId) + : undefined; + const sizeLabel = formatArtifactSize(artifact.size); + const opening = openingArtifactId === artifact.id; + + return ( +
+ + +
+ + {artifact.channelId ? ( + + ) : null} +
+
+ ); + })} +
+ ) : null} +
+
+ ); +} diff --git a/desktop/src/features/outbox/useOutboxArtifacts.ts b/desktop/src/features/outbox/useOutboxArtifacts.ts new file mode 100644 index 00000000000..f61f804957f --- /dev/null +++ b/desktop/src/features/outbox/useOutboxArtifacts.ts @@ -0,0 +1,40 @@ +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; +import { + buildOutboxArtifacts, + OUTBOX_MESSAGE_KINDS, +} from "@/features/outbox/lib/artifacts"; +import { relayClient } from "@/shared/api/relayClient"; +import { useRelayConnection } from "@/shared/api/useRelayConnection"; +import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; + +const OUTBOX_REFETCH_INTERVAL_MS = 30_000; + +export function useOutboxArtifacts() { + const knownAgentPubkeys = useKnownAgentPubkeys(); + const agentPubkeys = React.useMemo( + () => [...knownAgentPubkeys].sort(), + [knownAgentPubkeys], + ); + const connected = useRelayConnection() === "connected"; + const refetchInterval = useFocusedRefetchInterval( + connected && agentPubkeys.length > 0 ? OUTBOX_REFETCH_INTERVAL_MS : false, + ); + + return useQuery({ + queryKey: ["outbox-artifacts", ...agentPubkeys], + enabled: agentPubkeys.length > 0, + queryFn: async () => { + const events = await relayClient.fetchEvents({ + authors: agentPubkeys, + kinds: [...OUTBOX_MESSAGE_KINDS], + limit: 200, + }); + return buildOutboxArtifacts(events, new Set(agentPubkeys)); + }, + refetchInterval, + staleTime: 30_000, + }); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 6ca36bdc84c..9ead523e57e 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -112,6 +112,7 @@ export function AppSidebar({ onSelectPulse, onSelectWorkflows, onSelectHome, + onSelectOutbox, onSelectChannel, onOpenSearchResult, searchChannels, @@ -508,6 +509,7 @@ export function AppSidebar({ homeBadgeCount={homeBadgeCount} onSelectAgents={onSelectAgents} onSelectHome={onSelectHome} + onSelectOutbox={onSelectOutbox} onSelectProjects={onSelectProjects} onSelectPulse={onSelectPulse} onSelectWorkflows={onSelectWorkflows} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.types.ts b/desktop/src/features/sidebar/ui/AppSidebar.types.ts index ab35b8b598c..7120adf2cff 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.types.ts +++ b/desktop/src/features/sidebar/ui/AppSidebar.types.ts @@ -39,6 +39,7 @@ export type AppSidebarProps = { selectedChannelId: string | null; selectedView: | "home" + | "outbox" | "channel" | "messages" | "agents" @@ -87,6 +88,7 @@ export type AppSidebarProps = { onSelectPulse: () => void; onSelectWorkflows: () => void; onSelectHome: () => void; + onSelectOutbox: () => void; onSelectChannel: (channelId: string) => void; onOpenSearchResult: (hit: SearchHit, query: string) => void; /** Full channel set for global search, including channels outside the joined sidebar list. */ diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index a9bf7058eb6..e1aee39b92e 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -1,4 +1,4 @@ -import { Activity, Bot, Folders, Inbox, Zap } from "lucide-react"; +import { Activity, Bot, FileOutput, Folders, Inbox, Zap } from "lucide-react"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; import { SidebarProjectsSection } from "@/features/sidebar/ui/SidebarProjectsSection"; @@ -15,6 +15,7 @@ import { SidebarMenuLabel } from "@/shared/ui/sidebar-menu-label"; type SidebarSelectedView = | "home" + | "outbox" | "channel" | "messages" | "agents" @@ -42,6 +43,7 @@ type AppSidebarPrimaryMenuProps = { homeBadgeCount: number; onSelectAgents: () => void; onSelectHome: () => void; + onSelectOutbox: () => void; onSelectProjects: () => void; onSelectPulse: () => void; onSelectWorkflows: () => void; @@ -92,6 +94,7 @@ export function AppSidebarPrimaryMenu({ homeBadgeCount, onSelectAgents, onSelectHome, + onSelectOutbox, onSelectProjects, onSelectPulse, onSelectWorkflows, @@ -126,6 +129,19 @@ export function AppSidebarPrimaryMenu({ ) : null} + + + + Outbox + + { + await invokeTauri("open_artifact", { filename, url }); +} + /** Read plain text without depending on embedded-webview clipboard grants. */ export async function readTextFromSystemClipboard(): Promise { // E2E installs Tauri's mocked IPC surface in a browser page, where the SDK's diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 0bd7bc6eecc..d0612f50218 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -41,6 +41,8 @@ import { KIND_CHANNEL_WINDOW_BOUNDS, KIND_DM_VISIBILITY, KIND_EVENT_REMINDER, + KIND_FORUM_COMMENT, + KIND_FORUM_POST, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, @@ -57,6 +59,8 @@ import { KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, KIND_SYSTEM_MESSAGE, KIND_TEXT_NOTE, KIND_USER_STATUS, @@ -10450,6 +10454,38 @@ function sendToMockSocket(args: { } } if (!channelId) { + const messageKinds = new Set([ + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, + ]); + if ( + filter.authors?.length && + filter.kinds?.some((kind) => messageKinds.has(kind)) + ) { + const authors = new Set( + filter.authors.map((author) => author.toLowerCase()), + ); + const matchingEvents = [...mockMessages.values()] + .flat() + .filter( + (event) => + authors.has(event.pubkey.toLowerCase()) && + (filter.kinds?.includes(event.kind) ?? false) && + (filter.since === undefined || + event.created_at >= filter.since) && + (filter.until === undefined || event.created_at <= filter.until), + ) + .sort((left, right) => right.created_at - left.created_at) + .slice(0, filter.limit ?? 200); + for (const event of matchingEvents) { + sendWsText(socket.handler, ["EVENT", subId, event]); + } + sendWsText(socket.handler, ["EOSE", subId]); + return; + } + // Aux-backfill filters (reactions/deletions) are `#e`-keyed with no // channel tag — serve them across all channel stores like the relay. const referencedIds = filter["#e"]; @@ -13569,6 +13605,7 @@ export function maybeInstallE2eTauriMocks() { case "download_image": case "save_png_data_url": case "download_file": + case "open_artifact": case "save_agent_card": // The save dialog can't run headlessly; report a successful save so the // FileCard / image-menu click handlers resolve. Specs assert the diff --git a/desktop/tests/e2e/outbox.spec.ts b/desktop/tests/e2e/outbox.spec.ts new file mode 100644 index 00000000000..4f792bdba37 --- /dev/null +++ b/desktop/tests/e2e/outbox.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const ARTIFACT_EVENT_ID = "7".repeat(64); +const ARTIFACT_URL = `https://mock.relay/media/${"8".repeat(64)}.pdf`; + +test("Outbox opens agent artifacts and preserves their source conversation", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await expect(page.getByTestId("sidebar-primary-menu")).toBeVisible(); + + await page.evaluate( + ({ agentPubkey, artifactEventId, artifactUrl, humanPubkey }) => { + const emit = ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + createdAt: number; + extraTags: string[][]; + id: string; + pubkey: string; + }) => unknown; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("mock message emitter unavailable"); + + emit({ + channelName: "general", + content: `The launch brief is complete.\n\n[LAUNCH_BRIEF.pdf](${artifactUrl})`, + createdAt: Math.floor(Date.now() / 1_000), + extraTags: [ + ["buzz-outbox", "1"], + [ + "imeta", + `url ${artifactUrl}`, + "m application/pdf", + `x ${"8".repeat(64)}`, + "size 24576", + "filename LAUNCH_BRIEF.pdf", + ], + ], + id: artifactEventId, + pubkey: agentPubkey, + }); + + emit({ + channelName: "general", + content: `[HUMAN_NOTES.pdf](${artifactUrl})`, + createdAt: Math.floor(Date.now() / 1_000) + 1, + extraTags: [ + [ + "imeta", + `url ${artifactUrl}`, + "m application/pdf", + `x ${"9".repeat(64)}`, + "size 100", + "filename HUMAN_NOTES.pdf", + ], + ], + id: "9".repeat(64), + pubkey: humanPubkey, + }); + }, + { + agentPubkey: TEST_IDENTITIES.charlie.pubkey, + artifactEventId: ARTIFACT_EVENT_ID, + artifactUrl: ARTIFACT_URL, + humanPubkey: TEST_IDENTITIES.bob.pubkey, + }, + ); + + await page.getByTestId("open-outbox-view").click(); + await expect(page).toHaveURL(/\/outbox$/); + await expect(page.getByText("LAUNCH_BRIEF.pdf")).toBeVisible(); + await expect(page.getByText("HUMAN_NOTES.pdf")).toHaveCount(0); + await expect(page.getByTestId("outbox-artifact")).toHaveCount(1); + + await page.getByTestId("open-outbox-artifact").click(); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { __BUZZ_E2E_COMMANDS__?: string[] } + ).__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "open_artifact", + ).length ?? 0, + ), + ) + .toBe(1); + + await waitForAnimations(page); + await page.getByTestId("outbox-screen").screenshot({ + path: "test-results/outbox/outbox.png", + }); + + await page.getByTestId("open-outbox-source").click(); + await expect(page).toHaveURL( + new RegExp( + `/channels/${GENERAL_CHANNEL_ID}.*thread=%22${ARTIFACT_EVENT_ID}%22`, + ), + ); +});