diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 4a4c7c61b6..dff8240ea4 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -112,7 +112,6 @@ export default defineConfig({ name: "integration", testMatch: [ "**/agents.spec.ts", - "**/agent-snapshot-send.spec.ts", "**/agent-snapshot-recipient.spec.ts", "**/onboarding.spec.ts", "**/stream.spec.ts", diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index f13f82e80d..1ccae86ba5 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -223,6 +223,40 @@ pub async fn copy_image_to_clipboard( .map_err(|_| "clipboard result channel closed unexpectedly".to_string())? } +/// Write text to the system clipboard through the native shell. +/// +/// WebKit can revoke browser clipboard permission after a user action awaits a +/// long-running operation such as snapshot encoding and upload. Keeping the +/// delayed write in the native layer makes that flow reliable on macOS. +#[tauri::command] +pub async fn copy_text_to_clipboard( + text: String, + html: Option, + app: tauri::AppHandle, +) -> Result<(), String> { + let (tx, rx) = std::sync::mpsc::sync_channel::>(1); + app.run_on_main_thread(move || { + let result = arboard::Clipboard::new() + .map_err(|e| format!("clipboard error: {e}")) + .and_then(|mut clipboard| { + if let Some(html) = html { + clipboard + .set_html(html, Some(text)) + .map_err(|e| format!("clipboard error: {e}")) + } else { + clipboard + .set_text(text) + .map_err(|e| format!("clipboard error: {e}")) + } + }); + let _ = tx.send(result); + }) + .map_err(|e| format!("main thread dispatch failed: {e}"))?; + + rx.recv() + .map_err(|_| "clipboard result channel closed unexpectedly".to_string())? +} + /// Fetch blob bytes from a (pre-validated) relay media URL through the app's /// HTTP client, enforcing the download size cap. The caller is responsible for /// validating the URL origin and for any content-type checks on the result. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2a5e84d452..7f4897b7d2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -821,6 +821,7 @@ pub fn run() { download_file, fetch_media_bytes, copy_image_to_clipboard, + copy_text_to_clipboard, fetch_snapshot_bytes, list_relay_members, get_my_relay_membership, diff --git a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs b/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs new file mode 100644 index 0000000000..9439d4a36e --- /dev/null +++ b/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { clearLegacyPersonaCatalogVisibility } from "./legacyPersonaCatalogVisibility.ts"; + +test("clearLegacyPersonaCatalogVisibility removes the retired preference", () => { + const removedKeys = []; + + clearLegacyPersonaCatalogVisibility({ + removeItem(key) { + removedKeys.push(key); + }, + }); + + assert.deepEqual(removedKeys, ["buzz-persona-catalog-visibility-v1"]); +}); + +test("clearLegacyPersonaCatalogVisibility ignores unavailable storage", () => { + assert.doesNotThrow(() => clearLegacyPersonaCatalogVisibility(null)); + assert.doesNotThrow(() => + clearLegacyPersonaCatalogVisibility({ + removeItem() { + throw new Error("storage unavailable"); + }, + }), + ); +}); diff --git a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts b/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts new file mode 100644 index 0000000000..38b2d5d974 --- /dev/null +++ b/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts @@ -0,0 +1,28 @@ +const LEGACY_PERSONA_CATALOG_VISIBILITY_STORAGE_KEY = + "buzz-persona-catalog-visibility-v1"; + +/** + * Removes the retired custom-persona catalog preference so it cannot resurface + * agents after the visibility control has been removed. + */ +export function clearLegacyPersonaCatalogVisibility( + storage?: Pick | null, +) { + let targetStorage = storage; + if (targetStorage === undefined) { + if (typeof window === "undefined") return; + + try { + targetStorage = window.localStorage; + } catch { + return; + } + } + if (!targetStorage) return; + + try { + targetStorage.removeItem(LEGACY_PERSONA_CATALOG_VISIBILITY_STORAGE_KEY); + } catch { + // Catalog cleanup is best-effort and should not block the agents view. + } +} diff --git a/desktop/src/features/agents/ui/AgentSnapshotExportDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotExportDialog.tsx index 5147a29435..724e7d8f4c 100644 --- a/desktop/src/features/agents/ui/AgentSnapshotExportDialog.tsx +++ b/desktop/src/features/agents/ui/AgentSnapshotExportDialog.tsx @@ -1,7 +1,7 @@ import * as React from "react"; -import { AlertCircle, Download, Send } from "lucide-react"; +import { AlertCircle, Brain, Download, FileType2 } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import type { AgentPersona } from "@/shared/api/types"; import type { SnapshotFormat, SnapshotMemoryLevel, @@ -14,13 +14,12 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { Separator } from "@/shared/ui/separator"; -import { AgentSnapshotSendDialog } from "./AgentSnapshotSendDialog"; +import { SnapshotOptionMenu } from "./SnapshotOptionMenu"; type AgentSnapshotExportDialogProps = { + agentName: string; isSavePending: boolean; open: boolean; - persona: AgentPersona; /** Pubkey of the linked agent instance to use as the memory source. * When null, memory levels are disabled — the definition has no agent * instance with a keypair to read memory from. */ @@ -35,222 +34,170 @@ type AgentSnapshotExportDialogProps = { const MEMORY_LEVELS: { value: SnapshotMemoryLevel; label: string; - description: string; }[] = [ { value: "none", - label: "Config only", - description: "Exports definition and profile — no memory.", + label: "Agent only", }, { value: "core", - label: "Config + core memory", - description: "Includes the agent's core memory as plaintext.", + label: "Agent + core memory", }, { value: "everything", - label: "Config + all memory", - description: "Includes core and all mem/* entries as plaintext.", + label: "Agent + all memories", }, ]; +const FORMAT_OPTIONS: { value: SnapshotFormat; label: string }[] = [ + { value: "json", label: "JSON" }, + { value: "png", label: "PNG" }, +]; + +const MODAL_RESIZE_TRANSITION = { + duration: 0.22, + ease: [0.23, 1, 0.32, 1], +} as const; + export function AgentSnapshotExportDialog({ + agentName, isSavePending, open, - persona, linkedAgentPubkey, onSaveFile, onOpenChange, }: AgentSnapshotExportDialogProps) { const [memoryLevel, setMemoryLevel] = React.useState("none"); - const [format, setFormat] = React.useState("json"); - const [sendOpen, setSendOpen] = React.useState(false); + const [format, setFormat] = React.useState("png"); + const shouldReduceMotion = useReducedMotion(); const hasLinkedAgent = linkedAgentPubkey !== null; const showMemoryWarning = memoryLevel !== "none"; + const modalResizeTransition = shouldReduceMotion + ? { duration: 0 } + : MODAL_RESIZE_TRANSITION; // Reset state when the dialog opens for a fresh export. React.useEffect(() => { if (open) { setMemoryLevel("none"); - setFormat("json"); - setSendOpen(false); + setFormat("png"); } }, [open]); const isPending = isSavePending; return ( - <> - - - -
- Export agent snapshot -
- {/* Primary: Send in Buzz */} - - {/* Secondary: Save file */} - - - - -
+ Agent only + + )}
-
- - -
- {/* Agent identity */} -

- Exporting{" "} - - {persona.displayName} - {" "} - as a portable snapshot. The recipient imports it as a new{" "} - agent with fresh keys — identity never travels. -

- - {/* Memory level picker */} -
-

Memory to include

-
- {MEMORY_LEVELS.map(({ value, label, description }) => { - const memoryDisabled = !hasLinkedAgent && value !== "none"; - return ( - - ); - })} -
- {!hasLinkedAgent ? ( -

- Memory export requires a running agent instance. Start this - definition to enable memory levels. -

- ) : null} +
+ + + File format + + setFormat(value as SnapshotFormat)} + options={FORMAT_OPTIONS} + testId="agent-snapshot-format-trigger" + value={format} + />
+
- {/* Plaintext memory warning */} + {showMemoryWarning ? ( -
- -

- Memory is stored as plaintext in the - snapshot. Only share it with people you trust. -

-
+
+ +

+ Memory is stored as plaintext in the + snapshot. Only share it with people you trust. +

+
+ ) : null} +
- {/* Format picker */} -
-

File format

-
- - -
-

- Applies to saved files; snapshots shared in Buzz always use - .agent.png. PNG exports include memory when selected. -

-
+
+ + + +
- -
- - {/* Send-in-Buzz destination picker — opened as a secondary dialog */} - {sendOpen ? ( - { - setSendOpen(open); - }} - onSent={() => { - setSendOpen(false); - onOpenChange(false); - }} - /> - ) : null} - + + + ); } diff --git a/desktop/src/features/agents/ui/AgentSnapshotSendDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotSendDialog.tsx deleted file mode 100644 index a705b540a5..0000000000 --- a/desktop/src/features/agents/ui/AgentSnapshotSendDialog.tsx +++ /dev/null @@ -1,506 +0,0 @@ -import * as React from "react"; -import { AlertCircle, Check, Search, Send } from "lucide-react"; - -import type { AgentPersona } from "@/shared/api/types"; -import type { SnapshotMemoryLevel } from "@/shared/api/tauriPersonas"; -import { Button } from "@/shared/ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; -import { Separator } from "@/shared/ui/separator"; -import { useEncodeAgentSnapshotForSendMutation } from "@/features/agents/hooks"; -import { useTimeoutState } from "@/features/moderation/lib/timeoutStore"; -import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng"; -import { - useSnapshotSendController, - type SendPhase, - type ResolvedChannel, -} from "./useSnapshotSendController"; - -// ── Types ───────────────────────────────────────────────────────────────────── - -type AgentSnapshotSendDialogProps = { - open: boolean; - persona: AgentPersona; - linkedAgentPubkey: string | null; - memoryLevel: SnapshotMemoryLevel; - onOpenChange: (open: boolean) => void; - /** Called when the snapshot was successfully sent. */ - onSent: () => void; -}; - -// ── Sub-phases within the dialog ────────────────────────────────────────────── - -/** UI step within this dialog. */ -type DialogStep = - | "pick" // destination picker - | "memgate" // destination-scoped memory confirmation (memory-bearing only) - | "progress" // uploading / sending - | "done" // success - | "error"; // unrecoverable error - -// ── Component ───────────────────────────────────────────────────────────────── - -export function AgentSnapshotSendDialog({ - open, - persona, - linkedAgentPubkey, - memoryLevel, - onOpenChange, - onSent, -}: AgentSnapshotSendDialogProps) { - const controller = useSnapshotSendController(); - const encodeMutation = useEncodeAgentSnapshotForSendMutation(); - const timeoutState = useTimeoutState(); - - const [step, setStep] = React.useState("pick"); - const [selectedChannel, setSelectedChannel] = - React.useState(null); - const [search, setSearch] = React.useState(""); - - const hasMemory = memoryLevel !== "none"; - const isInProgress = - controller.state.phase === "preparing" || - controller.state.phase === "uploading" || - controller.state.phase === "sending"; - - // Reset all transient state when the dialog opens. - // biome-ignore lint/correctness/useExhaustiveDependencies: controller.reset and encodeMutation.reset are stable function references; only `open` drives the reset - React.useEffect(() => { - if (open) { - setStep("pick"); - setSelectedChannel(null); - setSearch(""); - controller.reset(); - encodeMutation.reset(); - } - }, [open]); - - // Mirror controller phase transitions to dialog step. - React.useEffect(() => { - if (controller.state.phase === "done") { - setStep("done"); - } else if (controller.state.phase === "error") { - setStep("error"); - } - }, [controller.state.phase]); - - // Reconcile selectedChannel when sendableChannels updates: - // - Clear if the selection left the list (e.g. archived, lost membership, - // or a moderation-DM filtered out once relay-self resolved). - // - Update to the current ResolvedChannel object if the selection is still - // present but its data (e.g. displayLabel) changed, so picker/memgate/done - // all use the same up-to-date resolved label. - React.useEffect(() => { - if (selectedChannel === null) return; - const current = controller.sendableChannels.find( - (ch) => ch.id === selectedChannel.id, - ); - if (!current) { - setSelectedChannel(null); - } else if (current !== selectedChannel) { - // Reconcile to the new object (label may have changed as profiles loaded). - setSelectedChannel(current); - } - }, [controller.sendableChannels, selectedChannel]); - - const filteredChannels = React.useMemo(() => { - const q = search.trim().toLowerCase(); - if (!q) return controller.sendableChannels; - return controller.sendableChannels.filter( - (ch) => - ch.displayLabel.toLowerCase().includes(q) || - ch.name.toLowerCase().includes(q), - ); - }, [controller.sendableChannels, search]); - - // ── Step handlers ────────────────────────────────────────────────────────── - - function handlePickConfirm() { - if (!selectedChannel) return; - if (hasMemory) { - // Memory-bearing: require explicit destination-scoped confirmation first. - setStep("memgate"); - } else { - void handleSend(selectedChannel); - } - } - - async function handleSend(destination: ResolvedChannel) { - // IMP3: refuse to send while the user is timed out. - if (timeoutState.active) { - controller.setErrorState( - "You are currently timed out and cannot send messages.", - ); - setStep("error"); - return; - } - - // Revalidate the destination is still in the sendable list. - // (Relay-self or identity may have resolved between pick and confirm.) - const stillSendable = controller.sendableChannels.some( - (ch) => ch.id === destination.id, - ); - if (!stillSendable) { - controller.setErrorState( - "The selected destination is no longer available. Please pick another.", - ); - setStep("error"); - return; - } - - setStep("progress"); - // beginSend reads from the React Query cache and timeout external store - // directly at two internal checkpoints — not from render-captured state. - // This closes the race between this pre-flight check and the moment encode - // or upload actually starts. - await controller.beginSend( - async () => - encodeMutation.mutateAsync({ - id: persona.id, - memoryLevel, - // PNG is the avatar card image and retains the snapshot contents. - // JSON has no relay-valid thumbnail. - format: "png", - memorySourcePubkey: linkedAgentPubkey, - avatarPngDataUrl: await resolveSnapshotAvatarPng(persona.avatarUrl), - }), - destination.id, - ); - // Phase transitions (done/error) are driven by beginSend via setState - // inside the controller; the mirror effect above keeps `step` in sync. - } - - function handleMemgateConfirm() { - if (selectedChannel) { - void handleSend(selectedChannel); - } - } - - function handleClose() { - if (!isInProgress) { - onOpenChange(false); - } - } - - function handleDoneClose() { - onOpenChange(false); - onSent(); - } - - // The resolved display label comes directly from the ResolvedChannel so the - // picker, memory-gate, and done copy all use the same resolved name. - const selectedLabel = React.useMemo(() => { - if (!selectedChannel) return null; - return selectedChannel.channelType === "dm" - ? `the DM with ${selectedChannel.displayLabel}` - : `#${selectedChannel.displayLabel}`; - }, [selectedChannel]); - - // ── Render ───────────────────────────────────────────────────────────────── - - return ( - - - -
- - {step === "done" - ? "Snapshot sent" - : step === "memgate" - ? "Confirm memory share" - : "Send snapshot in Buzz"} - - {step !== "progress" && step !== "done" && step !== "error" ? ( -
- {step === "pick" ? ( - - ) : ( - // memgate step - - )} - - - -
- ) : step === "done" ? ( - - ) : step === "error" ? ( -
- - - - -
- ) : null} -
-
- - - - {step === "pick" ? ( - - ) : step === "memgate" && selectedChannel !== null ? ( - - ) : step === "progress" ? ( - - ) : step === "done" && selectedChannel !== null ? ( - - ) : ( - - )} -
-
- ); -} - -// ── Pick step ───────────────────────────────────────────────────────────────── - -function PickStep({ - persona, - channels, - isLoadingChannels, - selectedChannel, - search, - onSearchChange, - onSelectChannel, -}: { - persona: AgentPersona; - channels: ResolvedChannel[]; - isLoadingChannels: boolean; - selectedChannel: ResolvedChannel | null; - search: string; - onSearchChange: (s: string) => void; - onSelectChannel: (ch: ResolvedChannel) => void; -}) { - return ( -
-

- Send{" "} - - {persona.displayName} - {" "} - as a snapshot attachment to a channel or DM. Recipients receive the - snapshot file and can import it locally. -

- - {/* Search */} -
- - onSearchChange(e.target.value)} - /> -
- - {/* Channel list */} -
- {isLoadingChannels ? ( -

- Loading… -

- ) : channels.length === 0 ? ( -

- {search - ? "No matching destinations." - : "No sendable destinations found."} -

- ) : ( - channels.map((ch) => ( - - )) - )} -
-
- ); -} - -// ── Memory gate step ────────────────────────────────────────────────────────── - -export function MemoryGateStep({ - destinationLabel, - memoryLevel, -}: { - destinationLabel: string; - memoryLevel: SnapshotMemoryLevel; -}) { - const scope = memoryLevel === "core" ? "core memory" : "all memory"; - - return ( -
-
- -
-

- This snapshot includes plaintext {scope}. -

-
    -
  • - The memory will be delivered to{" "} - {destinationLabel} and visible to everyone in - that recipient surface. -
  • -
  • - Anyone who obtains the uploaded media link will also be able to - fetch the raw snapshot bytes. -
  • -
-

- Only continue if you trust everyone who can see {destinationLabel}. -

-
-
-
- ); -} - -// ── Progress step ───────────────────────────────────────────────────────────── - -function ProgressStep({ phase }: { phase: SendPhase }) { - const label = - phase === "preparing" - ? "Preparing snapshot…" - : phase === "uploading" - ? "Uploading snapshot…" - : "Sending message…"; - return ( -
- {label} -
- ); -} - -// ── Done step ───────────────────────────────────────────────────────────────── - -function DoneStep({ destinationLabel }: { destinationLabel: string }) { - return ( -
-

- Snapshot sent to {destinationLabel} - . Recipients can download and import the snapshot file. -

-
- ); -} - -// ── Error step ──────────────────────────────────────────────────────────────── - -function ErrorStep({ error }: { error: string }) { - return ( -
- -

{error}

-
- ); -} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 15ccc43d48..20c85dffe8 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -13,6 +13,7 @@ import { AgentSnapshotExportDialog } from "./AgentSnapshotExportDialog"; import { AgentSnapshotImportDialog } from "./AgentSnapshotImportDialog"; import { TeamSnapshotExportDialog } from "./TeamSnapshotExportDialog"; import { TeamSnapshotImportDialog } from "./TeamSnapshotImportDialog"; +import { TeamShareDialog } from "./TeamShareDialog"; import { RelayDirectorySection } from "./RelayDirectorySection"; import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; @@ -146,7 +147,6 @@ export function AgentsView() { onDuplicatePersona={personas.openDuplicate} onEditPersona={personas.openEdit} onSharePersona={personas.openShare} - onExportPersonaSnapshot={personas.openExportSnapshot} onDeactivatePersona={(persona) => { void personas.handleSetActive(persona, false, "library"); }} @@ -173,7 +173,7 @@ export function AgentsView() { onDuplicate={teamActions.openDuplicateDialog} onEdit={teamActions.openEditDialog} onAddToChannel={teamActions.setTeamToAddToChannel} - onExport={teamActions.openExportSnapshot} + onShare={teamActions.openShare} onImport={() => { teamImportInputRef.current?.click(); }} @@ -289,23 +289,13 @@ export function AgentsView() { ) : null} {personas.personaToShare ? ( { - if (personas.personaToShare) { - personas.setPersonaCatalogVisibility( - personas.personaToShare, - visible, - ); - } - }} + linkedAgentPubkey={personas.personaToShare.linkedAgentPubkey} onExport={() => { - if (personas.personaToShare) { - personas.openShareExportSnapshot(personas.personaToShare); - } + const shareTarget = personas.personaToShare; + if (!shareTarget) return; + personas.setPersonaToShare(null); + personas.setPersonaToExportSnapshot(shareTarget); }} onOpenChange={(open) => { if (!open) { @@ -313,14 +303,14 @@ export function AgentsView() { } }} open={personas.personaToShare !== null} - persona={personas.personaToShare} + persona={personas.personaToShare.persona} /> ) : null} {personas.personaToExportSnapshot ? ( { if (personas.personaToExportSnapshot) { @@ -441,6 +431,29 @@ export function AgentsView() { team={teamActions.teamToAddToChannel} /> ) : null} + {teamActions.teamToShare ? ( + { + if (teamActions.teamToShare) { + const team = teamActions.teamToShare; + teamActions.setTeamToShare(null); + teamActions.openExportSnapshot(team); + } + }} + onOpenChange={(open) => { + if (!open) { + teamActions.setTeamToShare(null); + } + }} + open={teamActions.teamToShare !== null} + team={teamActions.teamToShare} + /> + ) : null} {teamActions.teamToExport ? ( void; onEdit: (persona: AgentPersona) => void; - onShare: (persona: AgentPersona) => void; - onExportSnapshot: ( + onShare: ( persona: AgentPersona, linkedAgent: ManagedAgent | undefined, ) => void; @@ -55,17 +51,13 @@ export function PersonaActionsMenu({ className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" type="button" > - + event.preventDefault()} > - onShare(persona)}> - - Catalog options - {canEdit ? ( onEdit(persona)}> @@ -81,10 +73,10 @@ export function PersonaActionsMenu({ onExportSnapshot(persona, linkedAgent)} + onClick={() => onShare(persona, linkedAgent)} > - - Export snapshot + + Share {persona.sourceTeam ? ( @@ -106,7 +98,7 @@ export function PersonaActionsMenu({ }} > - Remove from My Agents + Delete )} diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 2af86069e8..b6d3fafd3c 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -1,7 +1,36 @@ -import { BookUser, Download, X } from "lucide-react"; +import * as React from "react"; +import { + AlertCircle, + Check, + ChevronRight, + Download, + Link2, + X, +} from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { toast } from "sonner"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import type { AgentPersona } from "@/shared/api/types"; +import { useEncodeAgentSnapshotForSendMutation } from "@/features/agents/hooks"; +import { + useOpenDmMutation, + useUpsertCachedChannel, +} from "@/features/channels/hooks"; +import { buildSnapshotClipboardHtml } from "@/features/messages/lib/agentSnapshotClipboard"; +import { uploadMediaBytes, type BlobDescriptor } from "@/shared/api/tauri"; +import { copyTextToSystemClipboard } from "@/shared/api/tauriMedia"; +import type { SnapshotMemoryLevel } from "@/shared/api/tauriPersonas"; +import type { AgentPersona, UserSearchResult } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -11,96 +40,718 @@ import { DialogTitle, } from "@/shared/ui/dialog"; import { Separator } from "@/shared/ui/separator"; -import { Switch } from "@/shared/ui/switch"; +import { Spinner } from "@/shared/ui/spinner"; -import { PersonaAddedBy } from "./PersonaAddedBy"; +import { + formatShareRecipientName, + PersonaShareRecipients, +} from "./PersonaShareRecipients"; +import { SnapshotOptionMenu } from "./SnapshotOptionMenu"; +import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng"; +import { useSnapshotSendController } from "./useSnapshotSendController"; type PersonaShareDialogProps = { - isCatalogVisible: boolean; isPending: boolean; - onCatalogVisibilityChange: (visible: boolean) => void; + linkedAgentPubkey: string | null; onExport: () => void; onOpenChange: (open: boolean) => void; open: boolean; persona: AgentPersona; }; -export function PersonaShareDialog({ - isCatalogVisible, +type SnapshotShareDialogProps = { + displayName: string; + encodeSnapshot: ( + memoryLevel: SnapshotMemoryLevel, + ) => Promise<{ fileBytes: number[]; fileName: string }>; + hasMemoryOptions: boolean; + isPending: boolean; + onExport: () => void; + onOpenChange: (open: boolean) => void; + onReset?: () => void; + open: boolean; + snapshotKind: "agent" | "team"; + testIdPrefix: string; +}; + +type EncodedSnapshot = { + fileBytes: number[]; + fileName: string; +}; + +const RECIPIENT_ACTION_TRANSITION = { + duration: 0.18, + ease: [0.23, 1, 0.32, 1], +} as const; + +const SHARE_WARNING_TRANSITION = { + duration: 0.22, + ease: [0.23, 1, 0.32, 1], +} as const; + +const COPY_FEEDBACK_TRANSITION = { + duration: 0.14, + ease: [0.23, 1, 0.32, 1], +} as const; + +const COPY_BUTTON_LAYOUT_TRANSITION = { + duration: 0.2, + ease: [0.23, 1, 0.32, 1], +} as const; + +const COPY_FEEDBACK_RESET_MS = 1500; + +type CopyStatus = "idle" | "copying" | "copied"; + +type PendingMemoryShare = { + action: "copy" | "send"; + memoryLevel: Exclude; + recipientNames?: string[]; +}; + +function formatRecipientAudience(names: readonly string[]): string { + if (names.length === 0) return "The people you selected"; + if (names.length === 1) return names[0] ?? "The person you selected"; + if (names.length === 2) return `${names[0]} and ${names[1]}`; + return `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`; +} + +function MemoryShareConfirmation({ + itemLabel, + pendingShare, + onCancel, + onConfirm, + testIdPrefix, +}: { + itemLabel: string; + pendingShare: PendingMemoryShare | null; + onCancel: () => void; + onConfirm: (pendingShare: PendingMemoryShare) => void; + testIdPrefix: string; +}) { + const isLinkShare = pendingShare?.action === "copy"; + const memoryLabel = + pendingShare?.memoryLevel === "core" ? "core memory" : "all memories"; + const recipientAudience = formatRecipientAudience( + pendingShare?.recipientNames ?? [], + ); + + return ( + { + if (!nextOpen) onCancel(); + }} + open={pendingShare !== null} + > + + + Share memories? + + This {itemLabel} includes plaintext {memoryLabel}.{" "} + {isLinkShare + ? "Anyone with the link can view it." + : `${recipientAudience}—and anyone with the file link—can view it.`}{" "} + Only share with people you trust. + + + + + + + + + + + + + ); +} + +function ShareLevelControl({ + ariaLabel, + className, + disabled, + hasMemoryOptions, + onOpenChange, + staticClassName, + staticLabel, + testId, + value, + options, + onChange, +}: { + ariaLabel: string; + className?: string; + disabled: boolean; + hasMemoryOptions: boolean; + onOpenChange?: (open: boolean) => void; + staticClassName?: string; + staticLabel: string; + testId: string; + value: SnapshotMemoryLevel; + options: { value: SnapshotMemoryLevel; label: string }[]; + onChange: (level: SnapshotMemoryLevel) => void; +}) { + if (!hasMemoryOptions) { + return ( + + {staticLabel} + + ); + } + + return ( + onChange(nextValue as SnapshotMemoryLevel)} + options={options} + testId={testId} + value={value} + /> + ); +} + +export function SnapshotShareDialog({ + displayName, + encodeSnapshot, + hasMemoryOptions, isPending, - onCatalogVisibilityChange, onExport, onOpenChange, + onReset, open, - persona, -}: PersonaShareDialogProps) { - const switchId = `persona-share-catalog-${persona.id}`; + snapshotKind, + testIdPrefix, +}: SnapshotShareDialogProps) { + const openDmMutation = useOpenDmMutation(); + const upsertCachedChannel = useUpsertCachedChannel(); + const snapshotSendController = useSnapshotSendController(open); + const shouldReduceMotion = useReducedMotion(); + const [selectedRecipients, setSelectedRecipients] = React.useState< + UserSearchResult[] + >([]); + const [copyStatus, setCopyStatus] = React.useState("idle"); + const [pendingMemoryShare, setPendingMemoryShare] = + React.useState(null); + const [linkShareLevel, setLinkShareLevel] = + React.useState("none"); + const [recipientShareLevel, setRecipientShareLevel] = + React.useState("none"); + const encodedSnapshotCacheRef = React.useRef( + new Map>(), + ); + + const isSending = ["preparing", "uploading", "sending"].includes( + snapshotSendController.state.phase, + ); + const isCopying = copyStatus === "copying"; + const copyStatusLabel = + copyStatus === "copying" + ? "Copying…" + : copyStatus === "copied" + ? "Copied" + : "Copy link"; + const isActionPending = isPending || isCopying || isSending; + const isInterfacePending = isPending || isSending; + const hasSelectedRecipients = selectedRecipients.length > 0; + const showMemoryWarning = + linkShareLevel !== "none" || + (hasSelectedRecipients && recipientShareLevel !== "none"); + const recipientActionTransition = shouldReduceMotion + ? { duration: 0 } + : RECIPIENT_ACTION_TRANSITION; + const warningTransition = shouldReduceMotion + ? { duration: 0 } + : SHARE_WARNING_TRANSITION; + const copyFeedbackTransition = shouldReduceMotion + ? { duration: 0 } + : COPY_FEEDBACK_TRANSITION; + const copyButtonLayoutTransition = shouldReduceMotion + ? { duration: 0 } + : COPY_BUTTON_LAYOUT_TRANSITION; + const excludedRecipientPubkeys = React.useMemo( + () => + snapshotSendController.relaySelfPubkey + ? [snapshotSendController.relaySelfPubkey] + : [], + [snapshotSendController.relaySelfPubkey], + ); + const itemLabel = snapshotKind === "team" ? "team" : "agent"; + const itemLabelTitle = snapshotKind === "team" ? "Team" : "Agent"; + const shareLevels = React.useMemo( + () => [ + { value: "none" as const, label: `${itemLabelTitle} only` }, + { + value: "core" as const, + label: `${itemLabelTitle} + core memory`, + }, + { + value: "everything" as const, + label: `${itemLabelTitle} + all memories`, + }, + ], + [itemLabelTitle], + ); + const getEncodedSnapshot = React.useCallback( + (memoryLevel: SnapshotMemoryLevel) => { + const effectiveMemoryLevel = hasMemoryOptions ? memoryLevel : "none"; + const cached = encodedSnapshotCacheRef.current.get(effectiveMemoryLevel); + if (cached) return cached; + + const pending = encodeSnapshot(effectiveMemoryLevel).catch((error) => { + if ( + encodedSnapshotCacheRef.current.get(effectiveMemoryLevel) === pending + ) { + encodedSnapshotCacheRef.current.delete(effectiveMemoryLevel); + } + throw error; + }); + encodedSnapshotCacheRef.current.set(effectiveMemoryLevel, pending); + return pending; + }, + [encodeSnapshot, hasMemoryOptions], + ); + + React.useEffect(() => { + if (open) { + encodedSnapshotCacheRef.current.clear(); + setSelectedRecipients([]); + setCopyStatus("idle"); + setPendingMemoryShare(null); + setLinkShareLevel("none"); + setRecipientShareLevel("none"); + onReset?.(); + snapshotSendController.reset(); + } + }, [open, onReset, snapshotSendController.reset]); + + React.useEffect(() => { + if (copyStatus !== "copied") return; + + const resetTimer = window.setTimeout( + () => setCopyStatus("idle"), + COPY_FEEDBACK_RESET_MS, + ); + return () => window.clearTimeout(resetTimer); + }, [copyStatus]); + + async function uploadSnapshot( + memoryLevel: SnapshotMemoryLevel, + ): Promise { + const encoded = await getEncodedSnapshot(memoryLevel); + const uploaded = await uploadMediaBytes( + encoded.fileBytes, + encoded.fileName, + ); + const { thumb: _thumb, ...uploadedWithoutThumb } = uploaded; + + return { + ...uploadedWithoutThumb, + filename: encoded.fileName, + }; + } + + async function copyLink(memoryLevel: SnapshotMemoryLevel) { + if (isActionPending) return; + + setCopyStatus("copying"); + try { + const uploaded = await uploadSnapshot(memoryLevel); + await copyTextToSystemClipboard( + uploaded.url, + buildSnapshotClipboardHtml({ + attachment: uploaded, + displayName, + snapshotKind, + }), + ); + setCopyStatus("copied"); + } catch { + setCopyStatus("idle"); + toast.error("Couldn’t copy link. Try again."); + } + } + + async function sendToRecipients(memoryLevel: SnapshotMemoryLevel) { + if (isActionPending || selectedRecipients.length === 0) return; + + const sent = await snapshotSendController.beginSend( + () => getEncodedSnapshot(memoryLevel), + async () => { + const directMessage = await openDmMutation.mutateAsync({ + pubkeys: selectedRecipients.map((recipient) => recipient.pubkey), + }); + await upsertCachedChannel(directMessage); + return directMessage.id; + }, + displayName, + ); + + if (sent) { + toast.success(`Sent a copy of ${displayName}`); + onOpenChange(false); + } else if (sent === false) { + toast.error(`Couldn’t send ${itemLabel}. Try again.`); + } + } + + function requestMemoryShare( + action: PendingMemoryShare["action"], + memoryLevel: SnapshotMemoryLevel, + ) { + if (isActionPending) return; + + const effectiveMemoryLevel = hasMemoryOptions ? memoryLevel : "none"; + if (effectiveMemoryLevel !== "none") { + setPendingMemoryShare({ + action, + memoryLevel: effectiveMemoryLevel, + recipientNames: + action === "send" + ? selectedRecipients.map(formatShareRecipientName) + : undefined, + }); + return; + } + + if (action === "copy") { + void copyLink("none"); + } else { + void sendToRecipients("none"); + } + } + + function confirmMemoryShare(pendingShare: PendingMemoryShare) { + setPendingMemoryShare(null); + if (pendingShare.action === "copy") { + void copyLink(pendingShare.memoryLevel); + } else { + void sendToRecipients(pendingShare.memoryLevel); + } + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen && isActionPending) return; + onOpenChange(nextOpen); + } return ( - + - -
- Catalog options -
- + + ) : null} + +
+

- - Export - - - - Close - -

- -
- -
-
- -
-

- {persona.displayName} + They’ll receive a copy they can add and use. Changes you make + later won’t sync.

- {persona.isBuiltIn ? null : }
-
- + + {showMemoryWarning ? ( + +
+ +

+ Memory is stored as plaintext in the + snapshot. Only share it with people you trust. +

+
+
+ ) : null} +
-
-
- - -
- +
+
+ + + +
+

Share with a link

+

+ Anyone with the link can add and use a copy. +

+
+ +
+ +
+ +
+
+
+ setPendingMemoryShare(null)} + onConfirm={confirmMemoryShare} + pendingShare={pendingMemoryShare} + testIdPrefix={testIdPrefix} + />
); } + +export function PersonaShareDialog({ + isPending, + linkedAgentPubkey, + onExport, + onOpenChange, + open, + persona, +}: PersonaShareDialogProps) { + const encodeSnapshotMutation = useEncodeAgentSnapshotForSendMutation(); + const encodeSnapshot = React.useCallback( + async (memoryLevel: SnapshotMemoryLevel) => + encodeSnapshotMutation.mutateAsync({ + id: persona.id, + memoryLevel: linkedAgentPubkey ? memoryLevel : "none", + format: "png", + memorySourcePubkey: linkedAgentPubkey, + avatarPngDataUrl: await resolveSnapshotAvatarPng(persona.avatarUrl), + }), + [ + encodeSnapshotMutation.mutateAsync, + linkedAgentPubkey, + persona.avatarUrl, + persona.id, + ], + ); + + return ( + + ); +} diff --git a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx new file mode 100644 index 0000000000..4f8874914d --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx @@ -0,0 +1,309 @@ +import { Search } from "lucide-react"; +import * as React from "react"; + +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; +import { + useFlattenedUserSearchResults, + useInfiniteUserSearchQuery, + useUserSearchFetchMoreOnScroll, +} from "@/features/profile/hooks"; +import { + getKeyboardSearchSelection, + rankUserCandidatesBySearch, +} from "@/features/profile/lib/userCandidateSearch"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { UserSearchResult } from "@/shared/api/types"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { Skeleton } from "@/shared/ui/skeleton"; + +const RECIPIENT_LIMIT = 8; + +export function formatShareRecipientName(user: UserSearchResult) { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + truncatePubkey(user.pubkey) + ); +} + +export function PersonaShareRecipients({ + disabled, + excludedPubkeys = [], + onSelectionChange, + open, + renderEndControl, + selectedUsers, + testIdPrefix = "persona-share", +}: { + disabled: boolean; + excludedPubkeys?: readonly string[]; + onSelectionChange: (users: UserSearchResult[]) => void; + open: boolean; + renderEndControl?: (onOpenChange: (open: boolean) => void) => React.ReactNode; + selectedUsers: UserSearchResult[]; + testIdPrefix?: string; +}) { + const [searchQuery, setSearchQuery] = React.useState(""); + const [isPickerOpen, setIsPickerOpen] = React.useState(false); + const recipientFieldRef = React.useRef(null); + const searchInputRef = React.useRef(null); + const deferredSearchQuery = React.useDeferredValue(searchQuery.trim()); + const identityQuery = useIdentityQuery(); + const isArchived = useIsArchivedPredicate(); + const selectedPubkeys = React.useMemo( + () => new Set(selectedUsers.map((user) => normalizePubkey(user.pubkey))), + [selectedUsers], + ); + const excludedPubkeySet = React.useMemo( + () => new Set(excludedPubkeys.map(normalizePubkey)), + [excludedPubkeys], + ); + const userSearchQuery = useInfiniteUserSearchQuery(deferredSearchQuery, { + allowEmpty: true, + enabled: open && selectedUsers.length < RECIPIENT_LIMIT, + limit: 50, + }); + const userSearchResults = useFlattenedUserSearchResults(userSearchQuery.data); + const searchResults = React.useMemo(() => { + const currentPubkey = identityQuery.data?.pubkey + ? normalizePubkey(identityQuery.data.pubkey) + : null; + const candidates = userSearchResults.filter((user) => { + const pubkey = normalizePubkey(user.pubkey); + return ( + !user.isAgent && + pubkey !== currentPubkey && + !excludedPubkeySet.has(pubkey) && + !selectedPubkeys.has(pubkey) && + !isArchived(pubkey) + ); + }); + + return rankUserCandidatesBySearch({ + allowEmptyQuery: true, + candidates, + getLabel: formatShareRecipientName, + limit: 50, + query: deferredSearchQuery, + }); + }, [ + deferredSearchQuery, + excludedPubkeySet, + identityQuery.data?.pubkey, + isArchived, + selectedPubkeys, + userSearchResults, + ]); + const isSearchSettling = + userSearchQuery.isLoading || searchQuery.trim() !== deferredSearchQuery; + const visibleSearchResults = isSearchSettling ? [] : searchResults; + const handleDirectoryScroll = useUserSearchFetchMoreOnScroll( + userSearchQuery, + selectedUsers.length < RECIPIENT_LIMIT, + ); + + React.useEffect(() => { + if (!open) { + setSearchQuery(""); + setIsPickerOpen(false); + } + }, [open]); + + function selectUser(user: UserSearchResult) { + if (selectedUsers.length >= RECIPIENT_LIMIT) return; + onSelectionChange([...selectedUsers, user]); + setSearchQuery(""); + setIsPickerOpen(true); + searchInputRef.current?.focus({ preventScroll: true }); + } + + function removeUser(pubkey: string) { + onSelectionChange( + selectedUsers.filter( + (user) => normalizePubkey(user.pubkey) !== normalizePubkey(pubkey), + ), + ); + searchInputRef.current?.focus({ preventScroll: true }); + } + + return ( +
+ + + {/* biome-ignore lint/a11y/noStaticElementInteractions: the nested input is the keyboard-accessible focus target */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: clicking the shell focuses the nested search input */} +
{ + if (disabled) return; + setIsPickerOpen(true); + searchInputRef.current?.focus({ preventScroll: true }); + }} + ref={recipientFieldRef} + > +
+ {selectedUsers.length === 0 ? ( + + ) : null} + {selectedUsers.map((user) => ( + removeUser(user.pubkey)} + poofOnRemove={false} + testIds={{ + chip: `${testIdPrefix}-recipient-chip-${user.pubkey}`, + }} + user={user} + /> + ))} + = RECIPIENT_LIMIT} + onChange={(event) => { + setSearchQuery(event.target.value); + setIsPickerOpen(true); + }} + onFocus={() => setIsPickerOpen(true)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + setIsPickerOpen(false); + return; + } + + if ( + event.key === "Backspace" && + searchQuery.length === 0 && + selectedUsers.length > 0 + ) { + event.preventDefault(); + const lastUser = selectedUsers[selectedUsers.length - 1]; + if (lastUser) removeUser(lastUser.pubkey); + return; + } + + if (event.key !== "Enter") return; + const selection = getKeyboardSearchSelection({ + currentQuery: searchQuery, + rankedQuery: deferredSearchQuery, + results: visibleSearchResults, + }); + if (!selection) return; + event.preventDefault(); + selectUser(selection); + }} + placeholder={ + selectedUsers.length >= RECIPIENT_LIMIT + ? "Recipient limit reached" + : selectedUsers.length === 0 + ? "Search people" + : "" + } + ref={searchInputRef} + role="combobox" + spellCheck={false} + type="text" + value={searchQuery} + /> +
+ {selectedUsers.length > 0 && renderEndControl + ? renderEndControl((controlOpen) => { + if (controlOpen) setIsPickerOpen(false); + }) + : null} +
+
+ event.preventDefault()} + onInteractOutside={(event) => { + const target = event.detail.originalEvent.target; + if ( + target instanceof Element && + recipientFieldRef.current?.contains(target) + ) { + event.preventDefault(); + } + }} + onOpenAutoFocus={(event) => event.preventDefault()} + sideOffset={6} + > +
event.stopPropagation()} + onWheelCapture={(event) => event.stopPropagation()} + role="listbox" + > + {isSearchSettling ? ( +
+ {["w-36", "w-28", "w-40"].map((width) => ( +
+ + +
+ ))} +
+ ) : visibleSearchResults.length > 0 ? ( + visibleSearchResults.map((user) => ( + + )) + ) : ( +

+ No people found. +

+ )} +
+
+
+
+ ); +} diff --git a/desktop/src/features/agents/ui/SnapshotOptionMenu.tsx b/desktop/src/features/agents/ui/SnapshotOptionMenu.tsx new file mode 100644 index 0000000000..19539e5e39 --- /dev/null +++ b/desktop/src/features/agents/ui/SnapshotOptionMenu.tsx @@ -0,0 +1,75 @@ +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +export type SnapshotOption = { + value: string; + label: string; +}; + +export function SnapshotOptionMenu({ + ariaLabel, + className, + disabled = false, + onOpenChange, + options, + testId, + value, + onValueChange, +}: { + ariaLabel: string; + className?: string; + disabled?: boolean; + onOpenChange?: (open: boolean) => void; + options: readonly SnapshotOption[]; + testId: string; + value: string; + onValueChange: (value: string) => void; +}) { + const selectedLabel = + options.find((option) => option.value === value)?.label ?? ""; + + return ( + + + + + event.preventDefault()} + sideOffset={4} + style={{ + minWidth: "max(var(--radix-dropdown-menu-trigger-width), 13rem)", + }} + > + + {options.map((option) => ( + + {option.label} + + ))} + + + + ); +} diff --git a/desktop/src/features/agents/ui/TeamShareDialog.tsx b/desktop/src/features/agents/ui/TeamShareDialog.tsx new file mode 100644 index 0000000000..179af32b6a --- /dev/null +++ b/desktop/src/features/agents/ui/TeamShareDialog.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; + +import { encodeTeamSnapshotForSend } from "@/shared/api/tauriTeams"; +import type { AgentTeam } from "@/shared/api/types"; + +import { SnapshotShareDialog } from "./PersonaShareDialog"; + +type TeamShareDialogProps = { + isPending: boolean; + onExport: () => void; + onOpenChange: (open: boolean) => void; + open: boolean; + team: AgentTeam; +}; + +export function TeamShareDialog({ + isPending, + onExport, + onOpenChange, + open, + team, +}: TeamShareDialogProps) { + const encodeSnapshot = React.useCallback( + (memoryLevel: "none" | "core" | "everything") => + encodeTeamSnapshotForSend(team.id, memoryLevel, "png"), + [team.id], + ); + + return ( + + ); +} diff --git a/desktop/src/features/agents/ui/TeamSnapshotExportDialog.tsx b/desktop/src/features/agents/ui/TeamSnapshotExportDialog.tsx index e0d875bbee..da7f3dfd27 100644 --- a/desktop/src/features/agents/ui/TeamSnapshotExportDialog.tsx +++ b/desktop/src/features/agents/ui/TeamSnapshotExportDialog.tsx @@ -1,11 +1,12 @@ import * as React from "react"; -import { AlertCircle, Download, Send } from "lucide-react"; +import { AlertCircle, Brain, Download, FileType2 } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import type { AgentTeam } from "@/shared/api/types"; import type { SnapshotFormat, SnapshotMemoryLevel, } from "@/shared/api/tauriTeams"; +import type { AgentTeam } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -14,8 +15,8 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { Separator } from "@/shared/ui/separator"; -import { TeamSnapshotSendDialog } from "./TeamSnapshotSendDialog"; + +import { SnapshotOptionMenu } from "./SnapshotOptionMenu"; type TeamSnapshotExportDialogProps = { isSavePending: boolean; @@ -31,25 +32,22 @@ type TeamSnapshotExportDialogProps = { const MEMORY_LEVELS: { value: SnapshotMemoryLevel; label: string; - description: string; }[] = [ - { - value: "none", - label: "Config only", - description: "Exports team definition and member profiles — no memory.", - }, - { - value: "core", - label: "Config + core memory", - description: "Includes each member's core memory as plaintext.", - }, - { - value: "everything", - label: "Config + all memory", - description: "Includes core and all mem/* entries for every member.", - }, + { value: "none", label: "Team only" }, + { value: "core", label: "Team + core memory" }, + { value: "everything", label: "Team + all memories" }, +]; + +const FORMAT_OPTIONS: { value: SnapshotFormat; label: string }[] = [ + { value: "json", label: "JSON" }, + { value: "png", label: "PNG" }, ]; +const MODAL_RESIZE_TRANSITION = { + duration: 0.22, + ease: [0.23, 1, 0.32, 1], +} as const; + export function TeamSnapshotExportDialog({ isSavePending, open, @@ -59,177 +57,121 @@ export function TeamSnapshotExportDialog({ }: TeamSnapshotExportDialogProps) { const [memoryLevel, setMemoryLevel] = React.useState("none"); - const [format, setFormat] = React.useState("json"); - const [sendOpen, setSendOpen] = React.useState(false); - + const [format, setFormat] = React.useState("png"); + const shouldReduceMotion = useReducedMotion(); const showMemoryWarning = memoryLevel !== "none"; + const modalResizeTransition = shouldReduceMotion + ? { duration: 0 } + : MODAL_RESIZE_TRANSITION; - // Reset state when the dialog opens for a fresh export. React.useEffect(() => { if (open) { setMemoryLevel("none"); - setFormat("json"); - setSendOpen(false); + setFormat("png"); } }, [open]); - const isPending = isSavePending; - return ( - <> - - - -
- Export team snapshot -
- {/* Primary: Send in Buzz */} - - {/* Secondary: Save file */} - - - - -
-
-
- - + + + + Export {team.name} + -
- {/* Team identity */} -

- Exporting{" "} - {team.name}{" "} - as a portable snapshot. The recipient imports it as a new{" "} - team with fresh keys — identity never travels. -

+
+
+
+ + + Memories + + + setMemoryLevel(value as SnapshotMemoryLevel) + } + options={MEMORY_LEVELS} + testId="team-snapshot-memory-trigger" + value={memoryLevel} + /> +
- {/* Memory level picker */} -
-

Memory to include

-
- {MEMORY_LEVELS.map(({ value, label, description }) => ( - - ))} -
+
+ + + File format + + setFormat(value as SnapshotFormat)} + options={FORMAT_OPTIONS} + testId="team-snapshot-format-trigger" + value={format} + />
+
- {/* Plaintext memory warning */} + {showMemoryWarning ? ( -
- -

- Memory is stored as plaintext in the - snapshot. Only share it with people you trust. -

-
+
+ +

+ Memory is stored as plaintext in the + snapshot. Only share it with people you trust. +

+
+ ) : null} +
- {/* Format picker */} -
-

File format

-
- - -
-

- Applies to saved files; snapshots shared in Buzz always use - .team.png. PNG exports include memory when selected. -

-
+
+ + + +
- -
- - {/* Send-in-Buzz destination picker — opened as a secondary dialog */} - {sendOpen ? ( - { - setSendOpen(open); - }} - onSent={() => { - setSendOpen(false); - onOpenChange(false); - }} - /> - ) : null} - + +
+
); } diff --git a/desktop/src/features/agents/ui/TeamSnapshotSendDialog.tsx b/desktop/src/features/agents/ui/TeamSnapshotSendDialog.tsx deleted file mode 100644 index 4233ddfd8d..0000000000 --- a/desktop/src/features/agents/ui/TeamSnapshotSendDialog.tsx +++ /dev/null @@ -1,443 +0,0 @@ -import * as React from "react"; -import { AlertCircle, Check, Search, Send } from "lucide-react"; -import { useMutation } from "@tanstack/react-query"; - -import type { AgentTeam } from "@/shared/api/types"; -import type { SnapshotMemoryLevel } from "@/shared/api/tauriTeams"; -import { encodeTeamSnapshotForSend } from "@/shared/api/tauriTeams"; -import { buildTeamSnapshotSendArgs } from "./teamSnapshotImport.lib"; -import { Button } from "@/shared/ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; -import { Separator } from "@/shared/ui/separator"; -import { useTimeoutState } from "@/features/moderation/lib/timeoutStore"; -import { - useSnapshotSendController, - type SendPhase, - type ResolvedChannel, -} from "./useSnapshotSendController"; -import { MemoryGateStep } from "./AgentSnapshotSendDialog"; - -// ── Types ───────────────────────────────────────────────────────────────────── - -type TeamSnapshotSendDialogProps = { - open: boolean; - team: AgentTeam; - memoryLevel: SnapshotMemoryLevel; - onOpenChange: (open: boolean) => void; - /** Called when the snapshot was successfully sent. */ - onSent: () => void; -}; - -/** UI step within this dialog. */ -type DialogStep = - | "pick" // destination picker - | "memgate" // destination-scoped memory confirmation (memory-bearing only) - | "progress" // uploading / sending - | "done" // success - | "error"; // unrecoverable error - -// ── Component ───────────────────────────────────────────────────────────────── - -export function TeamSnapshotSendDialog({ - open, - team, - memoryLevel, - onOpenChange, - onSent, -}: TeamSnapshotSendDialogProps) { - const controller = useSnapshotSendController(); - const encodeMutation = useMutation({ - mutationFn: ({ - id, - memoryLevel, - format, - }: ReturnType) => - encodeTeamSnapshotForSend(id, memoryLevel, format), - }); - const timeoutState = useTimeoutState(); - - const [step, setStep] = React.useState("pick"); - const [selectedChannel, setSelectedChannel] = - React.useState(null); - const [search, setSearch] = React.useState(""); - - const hasMemory = memoryLevel !== "none"; - const isInProgress = - controller.state.phase === "preparing" || - controller.state.phase === "uploading" || - controller.state.phase === "sending"; - - // Reset all transient state when the dialog opens. - // biome-ignore lint/correctness/useExhaustiveDependencies: controller.reset and encodeMutation.reset are stable function references; only `open` drives the reset - React.useEffect(() => { - if (open) { - setStep("pick"); - setSelectedChannel(null); - setSearch(""); - controller.reset(); - encodeMutation.reset(); - } - }, [open]); - - // Mirror controller phase transitions to dialog step. - React.useEffect(() => { - if (controller.state.phase === "done") { - setStep("done"); - } else if (controller.state.phase === "error") { - setStep("error"); - } - }, [controller.state.phase]); - - // Reconcile selectedChannel when sendableChannels updates. - React.useEffect(() => { - if (selectedChannel === null) return; - const current = controller.sendableChannels.find( - (ch) => ch.id === selectedChannel.id, - ); - if (!current) { - setSelectedChannel(null); - } else if (current !== selectedChannel) { - setSelectedChannel(current); - } - }, [controller.sendableChannels, selectedChannel]); - - const filteredChannels = React.useMemo(() => { - const q = search.trim().toLowerCase(); - if (!q) return controller.sendableChannels; - return controller.sendableChannels.filter( - (ch) => - ch.displayLabel.toLowerCase().includes(q) || - ch.name.toLowerCase().includes(q), - ); - }, [controller.sendableChannels, search]); - - // ── Step handlers ────────────────────────────────────────────────────────── - - function handlePickConfirm() { - if (!selectedChannel) return; - if (hasMemory) { - setStep("memgate"); - } else { - void handleSend(selectedChannel); - } - } - - async function handleSend(destination: ResolvedChannel) { - if (timeoutState.active) { - controller.setErrorState( - "You are currently timed out and cannot send messages.", - ); - setStep("error"); - return; - } - - const stillSendable = controller.sendableChannels.some( - (ch) => ch.id === destination.id, - ); - if (!stillSendable) { - controller.setErrorState( - "The selected destination is no longer available. Please pick another.", - ); - setStep("error"); - return; - } - - setStep("progress"); - await controller.beginSend( - async () => - encodeMutation.mutateAsync( - buildTeamSnapshotSendArgs(team.id, memoryLevel), - ), - destination.id, - ); - } - - function handleMemgateConfirm() { - if (selectedChannel) { - void handleSend(selectedChannel); - } - } - - function handleClose() { - if (!isInProgress) { - onOpenChange(false); - } - } - - function handleDoneClose() { - onOpenChange(false); - onSent(); - } - - const selectedLabel = React.useMemo(() => { - if (!selectedChannel) return null; - return selectedChannel.channelType === "dm" - ? `the DM with ${selectedChannel.displayLabel}` - : `#${selectedChannel.displayLabel}`; - }, [selectedChannel]); - - // ── Render ───────────────────────────────────────────────────────────────── - - return ( - - - -
- - {step === "done" - ? "Snapshot sent" - : step === "memgate" - ? "Confirm memory share" - : "Send team snapshot in Buzz"} - - {step !== "progress" && step !== "done" && step !== "error" ? ( -
- {step === "pick" ? ( - - ) : ( - // memgate step - - )} - - - -
- ) : step === "done" ? ( - - ) : step === "error" ? ( -
- - - - -
- ) : null} -
-
- - - - {step === "pick" ? ( - - ) : step === "memgate" && selectedChannel !== null ? ( - - ) : step === "progress" ? ( - - ) : step === "done" && selectedChannel !== null ? ( - - ) : ( - - )} -
-
- ); -} - -// ── Pick step ───────────────────────────────────────────────────────────────── - -function PickStep({ - team, - channels, - isLoadingChannels, - selectedChannel, - search, - onSearchChange, - onSelectChannel, -}: { - team: AgentTeam; - channels: ResolvedChannel[]; - isLoadingChannels: boolean; - selectedChannel: ResolvedChannel | null; - search: string; - onSearchChange: (s: string) => void; - onSelectChannel: (ch: ResolvedChannel) => void; -}) { - return ( -
-

- Send {team.name} as - a team snapshot attachment to a channel or DM. Recipients receive the - snapshot file and can import it locally. -

- - {/* Search */} -
- - onSearchChange(e.target.value)} - /> -
- - {/* Channel list */} -
- {isLoadingChannels ? ( -

- Loading… -

- ) : channels.length === 0 ? ( -

- {search - ? "No matching destinations." - : "No sendable destinations found."} -

- ) : ( - channels.map((ch) => ( - - )) - )} -
-
- ); -} - -// ── Progress step ───────────────────────────────────────────────────────────── - -function ProgressStep({ phase }: { phase: SendPhase }) { - const label = - phase === "preparing" - ? "Preparing snapshot…" - : phase === "uploading" - ? "Uploading snapshot…" - : "Sending message…"; - return ( -
- {label} -
- ); -} - -// ── Done step ───────────────────────────────────────────────────────────────── - -function DoneStep({ destinationLabel }: { destinationLabel: string }) { - return ( -
-

- Team snapshot sent to{" "} - {destinationLabel}. Recipients can - download and import the snapshot file. -

-
- ); -} - -// ── Error step ──────────────────────────────────────────────────────────────── - -function ErrorStep({ error }: { error: string }) { - return ( -
- -

{error}

-
- ); -} diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index 3feb11eb42..988d45eccf 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -1,9 +1,9 @@ import { CopyPlus, - Download, - Ellipsis, + EllipsisVertical, Pencil, Rocket, + Share2, Trash2, Upload, } from "lucide-react"; @@ -35,7 +35,7 @@ type TeamsSectionProps = { onEdit: (team: AgentTeam) => void; onDelete: (team: AgentTeam) => void; onAddToChannel: (team: AgentTeam) => void; - onExport: (team: AgentTeam) => void; + onShare: (team: AgentTeam) => void; onImport: () => void; }; @@ -50,7 +50,7 @@ export function TeamsSection({ onEdit, onDelete, onAddToChannel, - onExport, + onShare, onImport, }: TeamsSectionProps) { return ( @@ -100,10 +100,10 @@ export function TeamsSection({ - onExport(team)} - > - - Export snapshot - onEdit(team)} @@ -139,6 +132,13 @@ export function TeamsSection({ Duplicate + onShare(team)} + > + + Share + ) : null} diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 5bcded76a8..f732d300bd 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -58,8 +58,7 @@ type UnifiedAgentsSectionProps = { onChooseCatalog: () => void; onDuplicatePersona: (persona: AgentPersona) => void; onEditPersona: (persona: AgentPersona) => void; - onSharePersona: (persona: AgentPersona) => void; - onExportPersonaSnapshot: ( + onSharePersona: ( persona: AgentPersona, linkedAgent: ManagedAgent | undefined, ) => void; @@ -98,7 +97,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onDuplicatePersona, onEditPersona, onSharePersona, - onExportPersonaSnapshot, onDeactivatePersona, onDeletePersona, onImportSnapshotFile, @@ -187,7 +185,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onDelete={onDeletePersona} onDuplicate={onDuplicatePersona} onEdit={onEditPersona} - onExportSnapshot={onExportPersonaSnapshot} onShare={onSharePersona} /> } diff --git a/desktop/src/features/agents/ui/agentSnapshotSend.test.mjs b/desktop/src/features/agents/ui/agentSnapshotSend.test.mjs index 32670594cf..0fc4ef0804 100644 --- a/desktop/src/features/agents/ui/agentSnapshotSend.test.mjs +++ b/desktop/src/features/agents/ui/agentSnapshotSend.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -// Tests for the snapshot send controller helpers and dialog behavior. +// Tests for the active snapshot send controller used by PersonaShareDialog. import { isSendableDestination, @@ -95,92 +95,6 @@ test("isSendableDestination_archived_dm_is_excluded", () => { assert.equal(isSendableDestination(ch), false); }); -// ── AgentSnapshotSendDialog memory gate rendering ───────────────────────────── -// -// MemoryGateStep is a pure function; we call it directly and walk the element -// tree to verify the two required disclosures appear for each memory level. - -import { MemoryGateStep } from "./AgentSnapshotSendDialog.tsx"; - -function collectText(element) { - const texts = []; - const queue = [element]; - while (queue.length > 0) { - const node = queue.shift(); - if (typeof node === "string") { - texts.push(node); - continue; - } - if (!node || typeof node !== "object") continue; - const children = node.props?.children; - if (Array.isArray(children)) { - queue.push(...children.flat(Infinity).filter(Boolean)); - } else if (typeof children === "string") { - texts.push(children); - } else if (children && typeof children === "object") { - queue.push(children); - } - } - return texts; -} - -function makeDestination(overrides = {}) { - return makeChannel({ - id: "ch-1", - name: "team-alpha", - channelType: "stream", - ...overrides, - }); -} - -// makeDestination is kept for potential future use. -void makeDestination; // suppress "unused" lint - -test("memory_gate_step_shows_plaintext_core_memory_label", () => { - const el = MemoryGateStep({ - destinationLabel: "#team-alpha", - memoryLevel: "core", - }); - const text = collectText(el).join(" "); - assert.match(text, /plaintext\s+core\s+memory/i, `got: ${text}`); -}); - -test("memory_gate_step_shows_plaintext_all_memory_label", () => { - const el = MemoryGateStep({ - destinationLabel: "#team-alpha", - memoryLevel: "everything", - }); - const text = collectText(el).join(" "); - assert.match(text, /plaintext\s+all\s+memory/i, `got: ${text}`); -}); - -test("memory_gate_step_names_channel_destination", () => { - const el = MemoryGateStep({ - destinationLabel: "#team-alpha", - memoryLevel: "core", - }); - const text = collectText(el).join(" "); - assert.match(text, /#team-alpha/i, `got: ${text}`); -}); - -test("memory_gate_step_names_dm_destination", () => { - const el = MemoryGateStep({ - destinationLabel: "the DM with Alice", - memoryLevel: "core", - }); - const text = collectText(el).join(" "); - assert.match(text, /the DM with Alice/i, `got: ${text}`); -}); - -test("memory_gate_step_discloses_media_link_access", () => { - const el = MemoryGateStep({ - destinationLabel: "#team-alpha", - memoryLevel: "core", - }); - const text = collectText(el).join(" "); - assert.match(text, /media link/i, `got: ${text}`); -}); - // ── createSendGuard: production concurrency guard ───────────────────────────── // // The UI hides the confirm button the moment handleSend transitions to the @@ -249,13 +163,19 @@ test("createSendGuard_sequential_calls_both_run", async () => { test("runGuardedSend_concurrent_calls_one_encodes_one_blocked", async () => { const guard = createSendGuard(); + let destinationCount = 0; let encodeCount = 0; let uploadCount = 0; let sendCount = 0; const states = []; - const makeDeps = () => ({ - channelId: "ch-1", + const resolveChannelId = async () => { + destinationCount++; + await new Promise((resolve) => setTimeout(resolve, 5)); + return "ch-1"; + }; + const makeDeps = (channelId) => ({ + channelId, checkEligibilityFn: () => null, encodeFn: async () => { encodeCount++; @@ -283,11 +203,20 @@ test("runGuardedSend_concurrent_calls_one_encodes_one_blocked", async () => { // Fire both concurrently using the same guard. const [r1, r2] = await Promise.all([ - runGuardedSend(guard, makeDeps()), - runGuardedSend(guard, makeDeps()), + runGuardedSend(guard, resolveChannelId, makeDeps, (s) => + states.push(s.phase), + ), + runGuardedSend(guard, resolveChannelId, makeDeps, (s) => + states.push(s.phase), + ), ]); - // Exactly one encode, upload, and send ran. + // Exactly one destination open, encode, upload, and send ran. + assert.equal( + destinationCount, + 1, + `expected destinationCount=1, got ${destinationCount}`, + ); assert.equal(encodeCount, 1, `expected encodeCount=1, got ${encodeCount}`); assert.equal(uploadCount, 1, `expected uploadCount=1, got ${uploadCount}`); assert.equal(sendCount, 1, `expected sendCount=1, got ${sendCount}`); diff --git a/desktop/src/features/agents/ui/snapshotAvatarPng.test.mjs b/desktop/src/features/agents/ui/snapshotAvatarPng.test.mjs index b8810d80db..a2b58b7406 100644 --- a/desktop/src/features/agents/ui/snapshotAvatarPng.test.mjs +++ b/desktop/src/features/agents/ui/snapshotAvatarPng.test.mjs @@ -37,8 +37,10 @@ test("resolveSnapshotAvatarPng: emoji SVG is rasterized onto a canvas", async () }, }; + const emojiSvg = + ''; const result = await resolveSnapshotAvatarPng( - "data:image/svg+xml,%3Csvg%2F%3E", + `data:image/svg+xml,${encodeURIComponent(emojiSvg)}`, { createCanvas: () => canvas, createImage: () => image, @@ -46,7 +48,12 @@ test("resolveSnapshotAvatarPng: emoji SVG is rasterized onto a canvas", async () ); assert.equal(result, "data:image/png;base64,cmFzdGVyaXplZA=="); - assert.equal(image.src, "data:image/svg+xml,%3Csvg%2F%3E"); + const rasterizedSvg = decodeURIComponent(image.src.split(",", 2)[1]); + assert.match( + rasterizedSvg, + //u, + ); + assert.doesNotMatch(rasterizedSvg, /rx="256"/u); assert.equal(canvas.width, 512); assert.equal(canvas.height, 512); assert.deepEqual(draws, [[image, 0, 0, 512, 512]]); diff --git a/desktop/src/features/agents/ui/snapshotAvatarPng.ts b/desktop/src/features/agents/ui/snapshotAvatarPng.ts index 60fab5bd58..dfeac34758 100644 --- a/desktop/src/features/agents/ui/snapshotAvatarPng.ts +++ b/desktop/src/features/agents/ui/snapshotAvatarPng.ts @@ -54,7 +54,7 @@ async function rasterizeSvg( ): Promise { try { const image = (dependencies.createImage ?? (() => new Image()))(); - image.src = svgDataUrl; + image.src = squareEmojiAvatarBackground(svgDataUrl); await image.decode(); const canvas = ( @@ -71,6 +71,36 @@ async function rasterizeSvg( } } +/** + * Emoji avatars use a circular SVG background in profile surfaces. Snapshot + * attachments already clip artwork to a rounded-square media slot, so remove + * that source-level circle before rasterizing to let the artwork fill the slot. + */ +function squareEmojiAvatarBackground(svgDataUrl: string) { + const commaIndex = svgDataUrl.indexOf(","); + if ( + commaIndex === -1 || + svgDataUrl.slice(0, commaIndex).includes(";base64") + ) { + return svgDataUrl; + } + + try { + const prefix = svgDataUrl.slice(0, commaIndex + 1); + const svg = decodeURIComponent(svgDataUrl.slice(commaIndex + 1)); + const squaredSvg = svg.replace( + /(]*\bwidth="512"[^>]*\bheight="512"[^>]*?)\s+rx="256"/u, + "$1", + ); + + return squaredSvg === svg + ? svgDataUrl + : `${prefix}${encodeURIComponent(squaredSvg)}`; + } catch { + return svgDataUrl; + } +} + function bytesToBase64(bytes: Uint8Array) { let binary = ""; const chunkSize = 0x8000; diff --git a/desktop/src/features/agents/ui/teamSnapshotImport.lib.ts b/desktop/src/features/agents/ui/teamSnapshotImport.lib.ts index f681603f6b..e3fc111aa2 100644 --- a/desktop/src/features/agents/ui/teamSnapshotImport.lib.ts +++ b/desktop/src/features/agents/ui/teamSnapshotImport.lib.ts @@ -1,8 +1,6 @@ import type { TeamSnapshotImportMemberResult, TeamSnapshotImportResult, - SnapshotMemoryLevel, - SnapshotFormat, } from "@/shared/api/tauriTeams"; // ── Import phase derivation ────────────────────────────────────────────────── @@ -63,19 +61,3 @@ export function deriveImportToast( message: `Imported ${result.team.name} with ${memberCount} member${memberCount === 1 ? "" : "s"}.`, }; } - -// ── Send-encode argument builder ───────────────────────────────────────────── - -export type TeamSnapshotSendArgs = { - id: string; - memoryLevel: SnapshotMemoryLevel; - format: SnapshotFormat; -}; - -/** Build the args for `encodeTeamSnapshotForSend`. In-Buzz sends always use PNG. */ -export function buildTeamSnapshotSendArgs( - teamId: string, - memoryLevel: SnapshotMemoryLevel, -): TeamSnapshotSendArgs { - return { id: teamId, memoryLevel, format: "png" }; -} diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index e5bac0024a..54535d121c 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -18,6 +18,7 @@ import { type AgentSnapshotImportResult, } from "@/features/agents/hooks"; import { getPersonaLibraryState } from "@/features/agents/lib/catalog"; +import { clearLegacyPersonaCatalogVisibility } from "@/features/agents/lib/legacyPersonaCatalogVisibility"; import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment"; import type { SnapshotFormat, @@ -48,48 +49,6 @@ import { type PersonaFeedbackSurface = "catalog" | "library"; -const PERSONA_CATALOG_VISIBILITY_STORAGE_KEY = - "buzz-persona-catalog-visibility-v1"; - -function readSharedCatalogPersonaIds(): string[] { - if (typeof window === "undefined") { - return []; - } - - try { - const raw = window.localStorage.getItem( - PERSONA_CATALOG_VISIBILITY_STORAGE_KEY, - ); - if (!raw) { - return []; - } - - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return []; - } - - return parsed.filter((id): id is string => typeof id === "string"); - } catch { - return []; - } -} - -function writeSharedCatalogPersonaIds(ids: string[]) { - if (typeof window === "undefined") { - return; - } - - try { - window.localStorage.setItem( - PERSONA_CATALOG_VISIBILITY_STORAGE_KEY, - JSON.stringify(ids), - ); - } catch { - // Catalog visibility is a local convenience setting; ignore storage failures. - } -} - export function usePersonaActions() { const queryClient = useQueryClient(); const personasQuery = usePersonasQuery(); @@ -111,8 +70,10 @@ export function usePersonaActions() { React.useState(null); const [personaToDelete, setPersonaToDelete] = React.useState(null); - const [personaToShare, setPersonaToShare] = - React.useState(null); + const [personaToShare, setPersonaToShare] = React.useState<{ + persona: AgentPersona; + linkedAgentPubkey: string | null; + } | null>(null); const [personaToExportSnapshot, setPersonaToExportSnapshot] = React.useState<{ persona: AgentPersona; linkedAgentPubkey: string | null; @@ -127,9 +88,6 @@ export function usePersonaActions() { const [snapshotImportConfirmError, setSnapshotImportConfirmError] = React.useState(null); const [isCatalogDialogOpen, setIsCatalogDialogOpen] = React.useState(false); - const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState< - string[] - >(readSharedCatalogPersonaIds); const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState< string | null >(null); @@ -143,10 +101,9 @@ export function usePersonaActions() { React.useState(false); const personas = personasQuery.data ?? []; - const sharedCatalogPersonaIdSet = React.useMemo( - () => new Set(sharedCatalogPersonaIds), - [sharedCatalogPersonaIds], - ); + React.useEffect(() => { + clearLegacyPersonaCatalogVisibility(); + }, []); const availableRuntimes = React.useMemo( () => (acpRuntimesQuery.data ?? []).filter( @@ -156,8 +113,8 @@ export function usePersonaActions() { [acpRuntimesQuery.data], ); const { catalogPersonas, libraryPersonas, personaLabelsById } = React.useMemo( - () => getPersonaLibraryState(personas, sharedCatalogPersonaIdSet), - [personas, sharedCatalogPersonaIdSet], + () => getPersonaLibraryState(personas), + [personas], ); function clearFeedback( @@ -385,22 +342,12 @@ export function usePersonaActions() { setPersonaToDelete(persona); } - function openShare(persona: AgentPersona) { - clearFeedback("library"); - setPersonaToShare(persona); - } - - function openShareExportSnapshot(persona: AgentPersona) { - setPersonaToShare(null); - openExportSnapshot(persona, undefined); - } - - function openExportSnapshot( + function openShare( persona: AgentPersona, linkedAgent: ManagedAgent | undefined, ) { clearFeedback("library"); - setPersonaToExportSnapshot({ + setPersonaToShare({ persona, linkedAgentPubkey: linkedAgent?.pubkey ?? null, }); @@ -439,29 +386,6 @@ export function usePersonaActions() { ); } - function setPersonaCatalogVisibility( - persona: AgentPersona, - visible: boolean, - ) { - if (persona.isBuiltIn) { - return; - } - - clearFeedback("library"); - setSharedCatalogPersonaIds((current) => { - const next = new Set(current); - if (visible) { - next.add(persona.id); - } else { - next.delete(persona.id); - } - - const ids = Array.from(next); - writeSharedCatalogPersonaIds(ids); - return ids; - }); - } - const isPending = isPersonaSubmitPending || createPersonaMutation.isPending || @@ -504,13 +428,9 @@ export function usePersonaActions() { openCatalog, openDelete, openShare, - openExportSnapshot, - openShareExportSnapshot, personaToExportSnapshot, setPersonaToExportSnapshot, handleExportSnapshot, - setPersonaCatalogVisibility, - sharedCatalogPersonaIdSet, clearFeedback, snapshotImportState, snapshotImportResult, diff --git a/desktop/src/features/agents/ui/useSnapshotSendController.ts b/desktop/src/features/agents/ui/useSnapshotSendController.ts index 2a16312bef..e14481b750 100644 --- a/desktop/src/features/agents/ui/useSnapshotSendController.ts +++ b/desktop/src/features/agents/ui/useSnapshotSendController.ts @@ -2,11 +2,9 @@ * useSnapshotSendController * * Payload-agnostic upload → send controller for sharing a snapshot to a Buzz - * channel or DM. The caller supplies an encode function and a destination; - * the controller drives prepare → encode → upload → send with honest progress, - * idempotent double-send protection, and fail-closed eligibility checks at two - * action-boundary checkpoints that read from live query-cache sources, not from - * render-captured snapshots. + * DM. The caller supplies an encode function and an async destination resolver; + * the controller guards destination creation plus prepare → encode → upload → + * send, with fail-closed eligibility checks at both action boundaries. * * This hook does not know what kind of snapshot the bytes contain. A future * team-snapshot or other payload can reuse it unchanged by passing different @@ -19,8 +17,11 @@ import type { QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; import { uploadMediaBytes, type BlobDescriptor } from "@/shared/api/tauri"; -import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown"; -import { channelsQueryKey, useChannelsQuery } from "@/features/channels/hooks"; +import { + buildOutgoingMessage, + formatImetaMediaLine, +} from "@/features/messages/lib/imetaMediaMarkdown"; +import { channelsQueryKey } from "@/features/channels/hooks"; import { isModerationDm } from "@/features/moderation/lib/moderationDm"; import { relaySelfQueryKey, @@ -30,8 +31,6 @@ import { getTimeoutSnapshot } from "@/features/moderation/lib/timeoutStore"; import { isTimeoutActive } from "@/features/moderation/lib/timeout"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useSendMessageMutation } from "@/features/messages/hooks"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels"; import type { Channel, Identity } from "@/shared/api/types"; // ── Public types ────────────────────────────────────────────────────────────── @@ -50,22 +49,8 @@ export type SnapshotSendState = { }; /** - * A channel annotated with a resolved display label. For non-DM channels the - * label equals `ch.name`; for DMs it resolves participant display names so the - * picker, memory-gate warning, and success copy are consistent. - */ -export type ResolvedChannel = Channel & { - /** Human-readable label for the channel (participant names for DMs). */ - displayLabel: string; -}; - -/** - * A joined, non-archived, non-moderation-DM destination: channelType "stream" - * or "dm", isMember true, archivedAt null. - * - * Moderation DM exclusion requires the relay `self` pubkey and the current - * user pubkey; those are applied in `useSendableChannels` below so callers - * always receive a fully-filtered list. + * A joined, non-archived, non-forum destination. Moderation DM exclusion is + * handled separately because it requires the current identity and relay self. */ export function isSendableDestination(ch: Channel): boolean { return ch.isMember && ch.archivedAt === null && ch.channelType !== "forum"; @@ -314,11 +299,13 @@ export async function runSendPipeline(deps: { } /** - * Compose the single-concurrency guard with the send pipeline. + * Compose the single-concurrency guard with destination resolution and the + * send pipeline. * * This is the exact production composition that `beginSend` uses: a second * concurrent call to `runGuardedSend` with the same guard receives `false` - * immediately — encode never starts for the blocked call. + * immediately — destination creation and encode never start for the blocked + * call. * * Exported so unit tests can import and call this exact function twice * concurrently with injected counters, proving one encode/upload/send and one @@ -327,24 +314,46 @@ export async function runSendPipeline(deps: { */ export function runGuardedSend( guard: ReturnType, - pipelineDeps: Parameters[0], + resolveChannelId: () => Promise, + buildPipelineDeps: ( + channelId: string, + ) => Parameters[0], + setStateFn: (state: SnapshotSendState) => void, ): Promise { - return guard.runGuarded(() => runSendPipeline(pipelineDeps)); + return guard.runGuarded(async () => { + // Mark the action pending before opening the DM so the active UI disables + // immediately. The in-memory guard still protects same-tick invocations + // that arrive before React commits that state. + setStateFn({ phase: "preparing", error: null }); + + let channelId: string; + try { + channelId = await resolveChannelId(); + } catch (error) { + setStateFn({ + phase: "error", + error: + error instanceof Error + ? `Couldn’t open the conversation: ${error.message}` + : "Couldn’t open the conversation.", + }); + return false; + } + + return runSendPipeline(buildPipelineDeps(channelId)); + }); } export type UseSnapshotSendControllerResult = { - /** - * Sendable destinations with resolved display labels. DMs are omitted - * while identity or relay-self are loading (fail-closed moderation-DM race). - */ - sendableChannels: ResolvedChannel[]; - /** True while channels, identity, or relay-self are loading. */ - isLoadingChannels: boolean; + /** Whether identity and relay-self are resolved for safe DM classification. */ + isDmSafetyReady: boolean; + /** Relay moderation identity to exclude from the people picker. */ + relaySelfPubkey: string | null; state: SnapshotSendState; /** - * Execute the full prepare → encode → upload → send sequence behind a - * single-concurrency guard. A second call while the first is in-flight - * returns `false` immediately — encode never starts for the blocked call. + * Execute destination creation plus prepare → encode → upload → send behind + * one concurrency guard. A second call while the first is in flight returns + * false before opening another DM or encoding another snapshot. * * Eligibility is checked at two internal checkpoints by reading directly * from the React Query cache and the timeout external store — not from @@ -359,47 +368,22 @@ export type UseSnapshotSendControllerResult = { */ beginSend: ( encodeFn: () => Promise<{ fileBytes: number[]; fileName: string }>, - channelId: string, - ) => Promise; - /** Set state to error with a message (for pre-send gate failures). */ - setErrorState: (message: string) => void; + resolveChannelId: () => Promise, + attachmentLabel?: string, + ) => Promise; reset: () => void; }; // ── Hook ────────────────────────────────────────────────────────────────────── -export function useSnapshotSendController(): UseSnapshotSendControllerResult { - const channelsQuery = useChannelsQuery(); +export function useSnapshotSendController( + enableDmSafety: boolean, +): UseSnapshotSendControllerResult { const identityQuery = useIdentityQuery(); const queryClient = useQueryClient(); - - // Only fetch relay self when there are DM candidates — same gate as ChannelPane. - const hasDmCandidates = React.useMemo( - () => - (channelsQuery.data ?? []).some( - (ch) => ch.channelType === "dm" && isSendableDestination(ch), - ), - [channelsQuery.data], - ); - const relaySelfQuery = useRelaySelfQuery(hasDmCandidates); - - // Collect the "other participant" pubkeys from all DM candidates so we can - // resolve their display names. Kept stable by memo so the batch query key - // doesn't flap on every render. - const dmParticipantPubkeys = React.useMemo(() => { - const currentPubkey = identityQuery.data?.pubkey?.toLowerCase(); - return (channelsQuery.data ?? []) - .filter((ch) => ch.channelType === "dm" && isSendableDestination(ch)) - .flatMap((ch) => - ch.participantPubkeys.filter( - (pk) => pk.toLowerCase() !== currentPubkey, - ), - ); - }, [channelsQuery.data, identityQuery.data]); - - const dmProfilesQuery = useUsersBatchQuery(dmParticipantPubkeys, { - enabled: dmParticipantPubkeys.length > 0, - }); + // The people picker can create the first DM in a workspace, so relay-self + // readiness cannot depend on an already-cached DM candidate. + const relaySelfQuery = useRelaySelfQuery(enableDmSafety); const [state, setState] = React.useState({ phase: "idle", @@ -413,76 +397,57 @@ export function useSnapshotSendController(): UseSnapshotSendControllerResult { // Pass null channel here — we supply the captured channelId per-send instead. const sendMutation = useSendMessageMutation(null, identityQuery.data); - const sendableChannels = React.useMemo(() => { - const currentPubkey = identityQuery.data?.pubkey; - const relaySelf = relaySelfQuery.data; - // Fail-closed: withhold ALL DMs until BOTH identity AND relay-self have - // successfully resolved (`status === "success"`). Absent, pending, - // fetching, and errored states are all unknown — we cannot classify - // whether a DM is a moderation DM without valid identity + relay-self. - // A successfully resolved `relaySelf === null` IS known: the relay - // advertises no self, and the moderation helper's fail-open applies. - const identitySuccess = identityQuery.status === "success"; - const relaySelfSuccess = relaySelfQuery.status === "success"; - const dmGateOpen = - !hasDmCandidates || (identitySuccess && relaySelfSuccess); - const dmProfiles = dmProfilesQuery.data?.profiles; - - return (channelsQuery.data ?? []) - .filter( - (ch) => - isSendableDestination(ch) && - !isModerationDm(ch, currentPubkey, relaySelf) && - (ch.channelType !== "dm" || dmGateOpen), - ) - .map((ch) => ({ - ...ch, - displayLabel: resolveChannelDisplayLabel(ch, currentPubkey, dmProfiles), - })); - }, [ - channelsQuery.data, - identityQuery.data, - identityQuery.status, - relaySelfQuery.data, - relaySelfQuery.status, - hasDmCandidates, - dmProfilesQuery.data, - ]); - async function beginSend( encodeFn: () => Promise<{ fileBytes: number[]; fileName: string }>, - channelId: string, - ): Promise { - return runGuardedSend(guardRef.current, { - encodeFn, - channelId, - // Eligibility is checked by reading directly from the React Query cache - // and the timeout external store — not from rendered React state. - checkEligibilityFn: () => checkSendEligibility(queryClient, channelId), - uploadFn: (bytes, filename) => uploadMediaBytes(bytes, filename), - sendFn: (args) => sendMutation.mutateAsync(args), - setStateFn: setState, - buildMessageFn: (descriptor) => buildOutgoingMessage("", [descriptor]), - }); + resolveChannelId: () => Promise, + attachmentLabel?: string, + ): Promise { + // A duplicate action is intentionally ignored; distinguish it from a + // failed send so the caller does not show a false failure toast. + if (guardRef.current.inFlight) return null; + + return runGuardedSend( + guardRef.current, + resolveChannelId, + (channelId) => ({ + encodeFn, + channelId, + // Eligibility is checked from live query-cache and timeout sources, + // not render-captured state. + checkEligibilityFn: () => checkSendEligibility(queryClient, channelId), + uploadFn: (bytes, filename) => uploadMediaBytes(bytes, filename), + sendFn: (args) => sendMutation.mutateAsync(args), + setStateFn: setState, + buildMessageFn: (descriptor) => { + const message = buildOutgoingMessage("", [descriptor]); + return attachmentLabel?.trim() + ? { + ...message, + content: formatImetaMediaLine(descriptor, { + label: attachmentLabel, + }), + } + : message; + }, + }), + setState, + ); } - function reset() { + const reset = React.useCallback(() => { if (!guardRef.current.inFlight) { setState({ phase: "idle", error: null }); } - } + }, []); return { - sendableChannels, - isLoadingChannels: - channelsQuery.isLoading || - (hasDmCandidates && - (relaySelfQuery.isLoading || identityQuery.isLoading)), + isDmSafetyReady: + !enableDmSafety || + (identityQuery.status === "success" && + relaySelfQuery.status === "success"), + relaySelfPubkey: relaySelfQuery.data ?? null, state, beginSend, - setErrorState: (message: string) => { - setState({ phase: "error", error: message }); - }, reset, }; } diff --git a/desktop/src/features/agents/ui/useTeamActions.ts b/desktop/src/features/agents/ui/useTeamActions.ts index 0c8e491274..64fe931cb9 100644 --- a/desktop/src/features/agents/ui/useTeamActions.ts +++ b/desktop/src/features/agents/ui/useTeamActions.ts @@ -66,6 +66,7 @@ export function useTeamActions( const [teamToExport, setTeamToExport] = React.useState( null, ); + const [teamToShare, setTeamToShare] = React.useState(null); const [teamSnapshotImportState, setTeamSnapshotImportState] = React.useState<{ fileBytes: number[]; fileName: string; @@ -225,6 +226,12 @@ export function useTeamActions( setTeamToExport(team); } + function openShare(team: AgentTeam) { + actions.setActionNoticeMessage(null); + actions.setActionErrorMessage(null); + setTeamToShare(team); + } + function handleExportTeamSnapshot( team: AgentTeam, memoryLevel: SnapshotMemoryLevel, @@ -320,6 +327,8 @@ export function useTeamActions( setTeamToAddToChannel, teamToExport, setTeamToExport, + teamToShare, + setTeamToShare, teamSnapshotImportState, teamSnapshotImportResult, teamSnapshotImportConfirmError, @@ -333,6 +342,7 @@ export function useTeamActions( openDuplicateDialog, openEditDialog, openExportSnapshot, + openShare, handleExportTeamSnapshot, handleImportTeamSnapshotFile, handleConfirmTeamSnapshotImport, diff --git a/desktop/src/features/agents/useAgentManagement.ts b/desktop/src/features/agents/useAgentManagement.ts index 4a1c410c3b..f4cdb895ef 100644 --- a/desktop/src/features/agents/useAgentManagement.ts +++ b/desktop/src/features/agents/useAgentManagement.ts @@ -20,7 +20,6 @@ import { import { availableRuntimesForStart, buildInstanceInputForDefinition, - mintDefinitionWithPreflight, type BackendIntent, } from "./lib/instanceInputForDefinition"; import { useCreatedAgentChannelAttachment } from "./useCreatedAgentChannelAttachment"; @@ -33,7 +32,6 @@ import type { CreatePersonaInput, UpdatePersonaInput, } from "@/shared/api/types"; -import { meshPrepareRelayMeshClient } from "@/shared/api/tauriMesh"; function updateInputFromRequest( request: Extract, @@ -201,15 +199,10 @@ export function useAgentManagement() { undefined, runtime.avatarUrl, ); - const persona = await mintDefinitionWithPreflight( - intent === "definition_start" ? backendIntent : null, - meshPrepareRelayMeshClient, - () => - createPersonaMutation.mutateAsync({ - ...input, - avatarUrl, - }), - ); + const persona = await createPersonaMutation.mutateAsync({ + ...input, + avatarUrl, + }); if (intent === "definition_start") { const created = await createAgentMutation.mutateAsync( diff --git a/desktop/src/features/messages/lib/agentSnapshotClipboard.test.mjs b/desktop/src/features/messages/lib/agentSnapshotClipboard.test.mjs new file mode 100644 index 0000000000..397f4b5dcf --- /dev/null +++ b/desktop/src/features/messages/lib/agentSnapshotClipboard.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildAgentSnapshotClipboardHtml, + buildTeamSnapshotClipboardHtml, + handleAgentSnapshotPaste, + parseAgentSnapshotClipboardHtml, +} from "./agentSnapshotClipboard.ts"; + +const SHA256 = "a".repeat(64); +const URL = `https://relay.example/media/${SHA256}.png`; + +function buildHtml(overrides = {}) { + return buildAgentSnapshotClipboardHtml({ + attachment: { + filename: "animation-auditor.agent.png", + sha256: SHA256, + size: 1234, + type: "image/png", + uploaded: 1, + url: URL, + ...overrides, + }, + displayName: "Animation Auditor", + }); +} + +test("copied agent HTML restores a labeled snapshot attachment", () => { + assert.deepEqual(parseAgentSnapshotClipboardHtml(buildHtml()), { + displayLabel: "Animation Auditor", + filename: "animation-auditor.agent.png", + sha256: SHA256, + size: 1234, + type: "image/png", + uploaded: 0, + url: URL, + }); +}); + +test("copied team HTML restores a labeled snapshot attachment", () => { + const html = buildTeamSnapshotClipboardHtml({ + attachment: { + filename: "design-review.team.png", + sha256: SHA256, + size: 4321, + type: "image/png", + uploaded: 1, + url: URL, + }, + displayName: "Design Review", + }); + + assert.deepEqual(parseAgentSnapshotClipboardHtml(html), { + displayLabel: "Design Review", + filename: "design-review.team.png", + sha256: SHA256, + size: 4321, + type: "image/png", + uploaded: 0, + url: URL, + }); +}); + +test("copied team HTML accepts snapshots above the agent size cap", () => { + const teamSize = 11 * 1024 * 1024; + const html = buildTeamSnapshotClipboardHtml({ + attachment: { + filename: "design-review.team.png", + sha256: SHA256, + size: teamSize, + type: "image/png", + uploaded: 1, + url: URL, + }, + displayName: "Design Review", + }); + + assert.equal(parseAgentSnapshotClipboardHtml(html)?.size, teamSize); +}); + +test("copied agent HTML escapes its visible link and name", () => { + const html = buildAgentSnapshotClipboardHtml({ + attachment: { + filename: "research.agent.png", + sha256: SHA256, + size: 1234, + type: "image/png", + uploaded: 1, + url: "https://relay.example/media/a.png?x=1&y=2", + }, + displayName: 'Research "One"', + }); + + assert.match(html, /Research <Agent> "One"/); + assert.match(html, /x=1&y=2/); +}); + +test("invalid copied snapshot metadata falls through to normal paste", () => { + const oversizedTeamHtml = buildTeamSnapshotClipboardHtml({ + attachment: { + filename: "design-review.team.png", + sha256: SHA256, + size: 51 * 1024 * 1024, + type: "image/png", + uploaded: 1, + url: URL, + }, + displayName: "Design Review", + }); + const invalid = [ + buildHtml({ sha256: "short" }), + buildHtml({ filename: "not-an-agent.png" }), + buildHtml({ size: 11 * 1024 * 1024 }), + oversizedTeamHtml, + buildHtml({ type: "text/html" }), + buildHtml({ url: "javascript:alert(1)" }), + buildHtml({ url: `${URL})[hidden](https://attacker.example` }), + buildHtml({ url: `${URL}\n[hidden](https://attacker.example)` }), + buildHtml({ url: `${URL} trailing` }), + ]; + + for (const html of invalid) { + assert.equal(parseAgentSnapshotClipboardHtml(html), null); + } +}); + +test("malformed copied snapshot field types fall through to normal paste", () => { + const validPayload = { + version: 1, + displayName: "Animation Auditor", + filename: "animation-auditor.agent.png", + sha256: SHA256, + size: 1234, + type: "image/png", + url: URL, + }; + + for (const override of [ + { displayName: 5 }, + { filename: { value: "animation-auditor.agent.png" } }, + { sha256: [SHA256] }, + ]) { + const encodedPayload = encodeURIComponent( + JSON.stringify({ ...validPayload, ...override }), + ); + const html = `Snapshot`; + + assert.doesNotThrow(() => parseAgentSnapshotClipboardHtml(html)); + assert.equal(parseAgentSnapshotClipboardHtml(html), null); + } +}); + +test("ordinary clipboard HTML is ignored", () => { + assert.equal( + parseAgentSnapshotClipboardHtml('link'), + null, + ); +}); + +test("snapshot paste adds one attachment and prevents the raw link paste", () => { + let attachments = []; + let preventDefaultCount = 0; + const event = { + clipboardData: { getData: () => buildHtml() }, + preventDefault: () => preventDefaultCount++, + }; + const setPending = (update) => { + attachments = update(attachments); + }; + + assert.equal(handleAgentSnapshotPaste(event, setPending), true); + assert.equal(handleAgentSnapshotPaste(event, setPending), true); + assert.equal(attachments.length, 1); + assert.equal(preventDefaultCount, 2); +}); diff --git a/desktop/src/features/messages/lib/agentSnapshotClipboard.ts b/desktop/src/features/messages/lib/agentSnapshotClipboard.ts new file mode 100644 index 0000000000..2a6aeb4a7b --- /dev/null +++ b/desktop/src/features/messages/lib/agentSnapshotClipboard.ts @@ -0,0 +1,177 @@ +import type { BlobDescriptor } from "@/shared/api/tauri"; +import type { ImetaMedia } from "./imetaMediaMarkdown"; + +// Keep the original attribute name for clipboard compatibility. The payload +// now accepts both agent and team snapshot filenames. +const CLIPBOARD_ATTRIBUTE = "data-buzz-agent-snapshot"; +const MAX_AGENT_SNAPSHOT_BYTES = 10 * 1024 * 1024; +const MAX_TEAM_SNAPSHOT_BYTES = 50 * 1024 * 1024; +const MAX_DISPLAY_NAME_LENGTH = 200; +const MAX_FILENAME_LENGTH = 255; + +type SnapshotClipboardPayload = { + version: 1; + displayName: string; + filename: string; + sha256: string; + size: number; + type: string; + url: string; +}; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function isSafeSnapshotUrl(value: string): boolean { + // The raw value is later emitted inside Markdown's `(url)` syntax. Keep + // delimiters and whitespace out even when URL parsing would normalize them. + if (/[\s()]/u.test(value)) return false; + + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:"; + } catch { + return false; + } +} + +/** Build Buzz-specific clipboard HTML with the raw URL as its visible link. */ +export function buildSnapshotClipboardHtml({ + attachment, + displayName, + snapshotKind, +}: { + attachment: ImetaMedia; + displayName: string; + snapshotKind: "agent" | "team"; +}): string { + const payload: SnapshotClipboardPayload = { + version: 1, + displayName, + filename: attachment.filename ?? `shared.${snapshotKind}.png`, + sha256: attachment.sha256, + size: attachment.size, + type: attachment.type, + url: attachment.url, + }; + const encodedPayload = encodeURIComponent(JSON.stringify(payload)); + + return `${escapeHtml(displayName)}`; +} + +export function buildAgentSnapshotClipboardHtml( + input: Omit[0], "snapshotKind">, +): string { + return buildSnapshotClipboardHtml({ ...input, snapshotKind: "agent" }); +} + +export function buildTeamSnapshotClipboardHtml( + input: Omit[0], "snapshotKind">, +): string { + return buildSnapshotClipboardHtml({ ...input, snapshotKind: "team" }); +} + +/** + * Restore a copied agent or team snapshot as composer attachment state. + * + * Clipboard HTML is untrusted input, so every field is checked before it can + * become an outbound imeta tag. Invalid or non-snapshot payloads fall through to + * the composer's normal paste behavior. + */ +export function parseSnapshotClipboardHtml(html: string): ImetaMedia | null { + const match = html.match( + new RegExp(`\\b${CLIPBOARD_ATTRIBUTE}=(?:"([^"]+)"|'([^']+)')`, "i"), + ); + const encodedPayload = match?.[1] ?? match?.[2]; + if (!encodedPayload) return null; + + let payload: unknown; + try { + payload = JSON.parse(decodeURIComponent(encodedPayload)); + } catch { + return null; + } + + if (!payload || typeof payload !== "object") return null; + const candidate = payload as Partial; + const displayName = + typeof candidate.displayName === "string" + ? candidate.displayName.trim() + : undefined; + const filename = + typeof candidate.filename === "string" + ? candidate.filename.trim() + : undefined; + const sha256 = + typeof candidate.sha256 === "string" + ? candidate.sha256.trim().toLowerCase() + : undefined; + const maxSnapshotBytes = filename?.toLowerCase().endsWith(".team.png") + ? MAX_TEAM_SNAPSHOT_BYTES + : MAX_AGENT_SNAPSHOT_BYTES; + + if ( + candidate.version !== 1 || + !displayName || + displayName.length > MAX_DISPLAY_NAME_LENGTH || + !filename || + filename.length > MAX_FILENAME_LENGTH || + filename.includes("/") || + filename.includes("\\") || + !/\.(?:agent|team)\.png$/i.test(filename) || + !sha256 || + !/^[0-9a-f]{64}$/.test(sha256) || + typeof candidate.size !== "number" || + !Number.isInteger(candidate.size) || + candidate.size <= 0 || + candidate.size > maxSnapshotBytes || + candidate.type !== "image/png" || + typeof candidate.url !== "string" || + !isSafeSnapshotUrl(candidate.url) + ) { + return null; + } + + return { + displayLabel: displayName, + filename, + sha256, + size: candidate.size, + type: "image/png", + uploaded: 0, + url: candidate.url, + }; +} + +export const parseAgentSnapshotClipboardHtml = parseSnapshotClipboardHtml; + +/** Convert a Buzz snapshot clipboard payload into one pending attachment. */ +export function handleSnapshotPaste( + event: Pick, + setPendingImeta: ( + update: (current: BlobDescriptor[]) => BlobDescriptor[], + ) => void, +): boolean { + const html = event.clipboardData?.getData("text/html") ?? ""; + const pastedSnapshot = parseSnapshotClipboardHtml(html); + if (!pastedSnapshot) return false; + + event.preventDefault(); + setPendingImeta((current) => + current.some( + (attachment) => + attachment.url === pastedSnapshot.url && + attachment.sha256 === pastedSnapshot.sha256, + ) + ? current + : [...current, pastedSnapshot], + ); + return true; +} + +export const handleAgentSnapshotPaste = handleSnapshotPaste; diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index 68804f16a9..20f2b4bba2 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + collectMessageAuthorPubkeys, collectReactionActorPubkeys, countTopLevelTimelineRows, formatTimelineMessages, @@ -169,6 +170,22 @@ test("non-deletion event kinds do NOT hide the target message", () => { assert.equal(out.length, 1, "the kind:9 message should still be visible"); }); +test("collectMessageAuthorPubkeys includes the signer and attributed actor", () => { + const events = [ + streamMessage({ + tags: [ + ["h", CHANNEL_ID], + ["actor", PUBKEY_B], + ], + }), + ]; + + assert.deepEqual( + new Set(collectMessageAuthorPubkeys(events)), + new Set([PUBKEY_A, PUBKEY_B]), + ); +}); + test("collectReactionActorPubkeys returns active kind:7 actors only", () => { const reactionId = `${"c".repeat(64)}`; const deletedReactionId = `${"d".repeat(64)}`; diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 9ce513896a..f17246e998 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -534,6 +534,7 @@ export function collectMessageAuthorPubkeys(events: RelayEvent[]) { pubkeys.add(pk); } } else { + pubkeys.add(event.pubkey.toLowerCase()); pubkeys.add( resolveEventAuthorPubkey({ pubkey: event.pubkey, diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index bac92361d2..a2edaa6f8c 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -13,6 +13,7 @@ import { formatImetaMediaLine, imetaMediaFromTags, mergeOutgoingTags, + restoreImetaMediaDisplayLabels, splitOutgoingTags, stripImetaMediaLines, } from "./imetaMediaMarkdown.ts"; @@ -139,6 +140,20 @@ test("formatImetaMediaLine: team snapshot PNG → filename link", () => { ); }); +test("formatImetaMediaLine: agent snapshot can use a display label", () => { + assert.equal( + formatImetaMediaLine( + { + url: "https://b/animation-auditor.png", + type: "image/png", + filename: "animation-auditor.agent.png", + }, + { label: "Animation Auditor" }, + ), + "\n[Animation Auditor](https://b/animation-auditor.png)", + ); +}); + test("buildImetaTags keeps media filenames in imeta", () => { // Filenames are included for every MIME type — the video review dialog // and file cards use them as display titles. @@ -221,6 +236,59 @@ test("strip: removes an escaped-bracket generic file line on edit", () => { assert.equal(stripped, "note"); }); +test("edit labels: restores an agent name from its durable attachment link", () => { + const url = "https://b/animation-auditor.png"; + const body = "note\n[Animation Auditor](https://b/animation-auditor.png)"; + + assert.deepEqual( + restoreImetaMediaDisplayLabels(body, [ + { + filename: "animation-auditor.agent.png", + type: "image/png", + url, + }, + ]), + [ + { + displayLabel: "Animation Auditor", + filename: "animation-auditor.agent.png", + type: "image/png", + url, + }, + ], + ); +}); + +test("edit labels: unescapes attachment labels without changing unmatched media", () => { + const labeledUrl = "https://b/labeled"; + const unmatched = { type: "application/pdf", url: "https://b/unmatched" }; + const body = String.raw`note +[A\]gent \\ Auditor](${labeledUrl})`; + + const restored = restoreImetaMediaDisplayLabels(body, [ + { type: "application/zip", url: labeledUrl }, + unmatched, + ]); + + assert.equal(restored[0].displayLabel, String.raw`A]gent \ Auditor`); + assert.equal(restored[1], unmatched); +}); + +test("edit labels: prefers the trailing attachment label over an earlier link", () => { + const url = "https://b/agent"; + const body = [ + `[Earlier link](${url})`, + "message", + `[Current agent name](${url})`, + ].join("\n"); + + const [restored] = restoreImetaMediaDisplayLabels(body, [ + { type: "image/png", url }, + ]); + + assert.equal(restored.displayLabel, "Current agent name"); +}); + test("findSpoileredImetaMediaUrls: extracts only spoilered matching media urls", () => { const body = [ "note", @@ -457,6 +525,21 @@ test("buildOutgoingMessage: team snapshot PNG uses the snapshot-card link path", ); }); +test("buildOutgoingMessage: copied agent snapshot keeps its display label", () => { + const out = buildOutgoingMessage("", [ + { + url: "https://b/analyst.png", + type: "image/png", + sha256: "x", + size: 1, + uploaded: 0, + filename: "analyst.agent.png", + displayLabel: "Animation Auditor", + }, + ]); + assert.equal(out.content, "\n[Animation Auditor](https://b/analyst.png)"); +}); + test("buildOutgoingMessage: wraps spoilered image and video attachments", () => { const out = buildOutgoingMessage( "hi", diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index 5fd028bc23..d988db9dd5 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -28,7 +28,10 @@ import type { BlobDescriptor } from "@/shared/api/tauri"; import { parseImetaTags } from "./parseImeta"; -export type ImetaMedia = BlobDescriptor; +export type ImetaMedia = BlobDescriptor & { + /** Composer-only label used for attachment links; not emitted in imeta. */ + displayLabel?: string; +}; /** * Project a Nostr event's imeta tags into the `BlobDescriptor[]` shape the @@ -113,6 +116,42 @@ const BLOCK_SPOILER_DELIMITER_RE = /^\s*\|\|\s*$/; * file attachments from the body in edit mode. */ const FILE_LINE_RE = /^\[(?:\\.|[^\]\\])*\]\(([^)\s]+)\)\s*$/; +const LABELED_FILE_LINE_RE = /^\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)\s*$/; + +function unescapeMarkdownLinkLabel(label: string): string { + return label.replace(/\\([\\[\]])/g, "$1"); +} + +/** + * Restore composer-only attachment labels from the durable markdown link in + * an existing message body. NIP-92 imeta tags preserve the attachment file + * metadata but not the visible link label, so edit mode must join the two + * representations before seeding pending attachments. + */ +export function restoreImetaMediaDisplayLabels( + body: string, + imetaMedia: ReadonlyArray, +): ImetaMedia[] { + if (imetaMedia.length === 0) return []; + + const urls = new Set(imetaMedia.map((media) => media.url)); + const labelsByUrl = new Map(); + const lines = body.split("\n"); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]; + const match = line.match(LABELED_FILE_LINE_RE); + const url = match?.[2]; + if (!url || !urls.has(url) || labelsByUrl.has(url)) continue; + + const label = unescapeMarkdownLinkLabel(match[1]).trim(); + if (label) labelsByUrl.set(url, label); + } + + return imetaMedia.map((media) => { + const displayLabel = labelsByUrl.get(media.url); + return displayLabel ? { ...media, displayLabel } : media; + }); +} function findTrailingBlockSpoilerMediaStart( lines: string[], @@ -240,7 +279,7 @@ export function findSpoileredImetaMediaUrls( */ export function formatImetaMediaLine( { url, type, filename }: ImetaMedia, - options: { spoiler?: boolean } = {}, + options: { label?: string; spoiler?: boolean } = {}, ): string { // A PNG snapshot is image/png on the wire, but it is an importable file, not // inline media. Keep it on the anchor renderer's snapshot-card path. @@ -255,8 +294,11 @@ export function formatImetaMediaLine( const line = `![image](${url})`; return options.spoiler ? `\n||${line}||` : `\n${line}`; } - // Generic file: plain link, label is the original filename (fallback to url tail). - const label = filename || url.split("/").pop() || "file"; + // Generic file: plain link, label is the caller-provided display label when + // available, otherwise the original filename (falling back to the URL tail). + // The filename remains in imeta for download/import integrity. + const label = + options.label?.trim() || filename || url.split("/").pop() || "file"; // Escape markdown link-label metacharacters so filenames containing `[`, `]`, // or `\` (e.g. `a].pdf`) still render as a FileCard with the correct label // rather than breaking the link or mangling the visible text. @@ -283,6 +325,7 @@ export function buildOutgoingMessage( let content = body; for (const d of pendingImeta) { content += formatImetaMediaLine(d, { + label: d.displayLabel, spoiler: spoileredMediaUrls.has(d.url), }); } diff --git a/desktop/src/features/messages/lib/snapshotSharedBy.test.mjs b/desktop/src/features/messages/lib/snapshotSharedBy.test.mjs new file mode 100644 index 0000000000..5d66a3ae10 --- /dev/null +++ b/desktop/src/features/messages/lib/snapshotSharedBy.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveSnapshotSharedBy } from "./snapshotSharedBy.ts"; + +const SIGNER = "a".repeat(64); +const ATTRIBUTED_AUTHOR = "b".repeat(64); + +test("snapshot provenance resolves the raw signer profile", () => { + const label = resolveSnapshotSharedBy( + { signerPubkey: SIGNER }, + { + [SIGNER]: { + avatarUrl: null, + displayName: "Signer name", + nip05Handle: null, + }, + [ATTRIBUTED_AUTHOR]: { + avatarUrl: null, + displayName: "Spoofed actor name", + nip05Handle: null, + }, + }, + ); + + assert.equal(label, "Signer name"); +}); + +test("snapshot provenance is omitted when the raw signer is unavailable", () => { + assert.equal(resolveSnapshotSharedBy({}, {}), undefined); +}); diff --git a/desktop/src/features/messages/lib/snapshotSharedBy.ts b/desktop/src/features/messages/lib/snapshotSharedBy.ts new file mode 100644 index 0000000000..1fb0f5f9d5 --- /dev/null +++ b/desktop/src/features/messages/lib/snapshotSharedBy.ts @@ -0,0 +1,18 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; + +/** Resolve attachment provenance from the raw event signer, never actor tags. */ +export function resolveSnapshotSharedBy( + message: Pick, + profiles?: UserProfileLookup, +): string | undefined { + if (!message.signerPubkey) return undefined; + + return resolveUserLabel({ + profiles, + pubkey: message.signerPubkey, + }); +} diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 78c9e69d86..293eefef7b 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -239,10 +239,10 @@ export function clearDraftEntry(draftKey: string): void { /** * Return true only when every field of two DraftState values is identical, - * including all BlobDescriptor optional fields (dim, blurhash, thumb, - * duration, image, filename, uploaded). Any divergence — including selection - * offsets, timestamps, attachment metadata, spoiler state, and status — is - * treated as a distinct record that must not be discarded. + * including all ImetaMedia optional fields (dim, blurhash, thumb, duration, + * image, filename, displayLabel, uploaded). Any divergence — including + * selection offsets, timestamps, attachment metadata, spoiler state, and + * status — is treated as a distinct record that must not be discarded. */ function draftStatesEqual(a: DraftState, b: DraftState): boolean { if ( @@ -272,7 +272,8 @@ function draftStatesEqual(a: DraftState, b: DraftState): boolean { am.thumb !== bm.thumb || am.duration !== bm.duration || am.image !== bm.image || - am.filename !== bm.filename + am.filename !== bm.filename || + am.displayLabel !== bm.displayLabel ) { return false; } diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index 897a8aac3b..6a14aa498a 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -1,9 +1,18 @@ import * as React from "react"; import { AnimatePresence, LayoutGroup, motion } from "motion/react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { FileText, HatGlasses, Pencil, Play, X } from "lucide-react"; +import { + Bot, + FileText, + HatGlasses, + Pencil, + Play, + Users, + X, +} from "lucide-react"; import type { BlobDescriptor } from "@/shared/api/tauri"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { shortHash, @@ -11,6 +20,15 @@ import { } from "@/features/messages/lib/useMediaUpload"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { + Attachment, + AttachmentAction, + AttachmentActions, + AttachmentContent, + AttachmentDescription, + AttachmentMedia, + AttachmentTitle, +} from "@/shared/ui/attachment"; import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop"; import { Progress } from "@/shared/ui/progress"; import { Toggle } from "@/shared/ui/toggle"; @@ -34,7 +52,7 @@ export function DropZoneOverlay({ className }: { className?: string }) { } type ComposerAttachmentsProps = { - attachments: BlobDescriptor[]; + attachments: ImetaMedia[]; isUploading?: boolean; onCancelUpload?: (previewId: number) => void; uploadingCount?: number; @@ -50,6 +68,113 @@ type ComposerAttachmentsProps = { spoileredUrls?: ReadonlySet; }; +function formatAttachmentSize(size: number): string { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +type SnapshotKind = "agent" | "team"; + +function getSnapshotKind(attachment: ImetaMedia): SnapshotKind | null { + const filename = attachment.filename?.toLowerCase(); + if (attachment.sha256.length !== 64) return null; + if (filename?.endsWith(".agent.png") || filename?.endsWith(".agent.json")) { + return "agent"; + } + if (filename?.endsWith(".team.png") || filename?.endsWith(".team.json")) { + return "team"; + } + return null; +} + +function ComposerSnapshotCard({ + attachment, + onRemove, + snapshotKind, +}: { + attachment: ImetaMedia; + onRemove: (url: string) => void; + snapshotKind: SnapshotKind; +}) { + const [thumbError, setThumbError] = React.useState(false); + const isAgentPng = + snapshotKind === "agent" && + attachment.filename?.toLowerCase().endsWith(".agent.png"); + const showThumb = isAgentPng && !thumbError; + const SnapshotIcon = snapshotKind === "team" ? Users : Bot; + const fallbackLabel = snapshotKind === "team" ? "Team" : "Agent"; + const displayName = + attachment.displayLabel?.trim() || + attachment.filename?.replace(/\.(?:agent|team)\.(?:png|json)$/i, "") || + fallbackLabel; + + return ( + + + + {showThumb ? ( + <> + + setThumbError(true)} + /> + + ) : ( + + )} + + + + {displayName} + + + {formatAttachmentSize(attachment.size)} + + + + onRemove(attachment.url)} + title="Remove" + type="button" + > + + + + + + ); +} + const LIGHTBOX_BUTTON_CLASS = "rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-hidden focus:ring-2 focus:ring-white/30"; @@ -410,6 +535,18 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ const isImage = attachment.type.startsWith("image/"); const isFile = !isVideo && !isImage; + const snapshotKind = getSnapshotKind(attachment); + if (snapshotKind) { + return ( + + ); + } + // Generic file: compact chip with a file icon + filename, plus the // same remove button. No lightbox (nothing to preview). if (isFile) { diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index b56b0fa9c3..2cd9a7b6c2 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { EditorContent } from "@tiptap/react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; +import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; import { useDrafts } from "@/features/messages/lib/useDrafts"; @@ -15,6 +16,7 @@ import { findSpoileredImetaMediaUrls, type ImetaMedia, mergeOutgoingTags, + restoreImetaMediaDisplayLabels, stripImetaMediaLines, } from "@/features/messages/lib/imetaMediaMarkdown"; @@ -368,21 +370,19 @@ function MessageComposerImpl({ // Strip the trailing `![image|video](url)` lines that correspond to // imeta attachments — the user manages those via the attachments row, // not via raw markdown in the editor. - const editableBody = stripImetaMediaLines( + const editableImeta = restoreImetaMediaDisplayLabels( editTarget.body, editTarget.imetaMedia ?? [], ); + const editableBody = stripImetaMediaLines(editTarget.body, editableImeta); setComposerContent(editableBody); richText.setContent(editableBody); // Seed the composer's pending-imeta state with the original event's // attachments so they show up in `ComposerAttachments` and the user // can remove existing ones / add new ones before saving. - media.setPendingImeta(editTarget.imetaMedia ?? []); + media.setPendingImeta(editableImeta); setSpoileredAttachmentUrls( - findSpoileredImetaMediaUrls( - editTarget.body, - editTarget.imetaMedia ?? [], - ), + findSpoileredImetaMediaUrls(editTarget.body, editableImeta), ); // Defer focus to the next frame so it runs after any focus- // restoration the trigger UI (e.g. the message-row context menu) @@ -810,12 +810,10 @@ function MessageComposerImpl({ return true; } - // --- Mention / channel-link normalization --- - // When copying from the chat area the browser puts styled HTML - // on the clipboard. The mention/channel-link wrappers have - // font-weight:600 which Tiptap's Bold extension misinterprets - // as bold. Strip those wrappers and use ProseMirror's pasteHTML - // to parse the cleaned HTML into proper rich content nodes. + // Restore Buzz snapshots before normal styled-HTML normalization. + if (handleAgentSnapshotPaste(event, media.setPendingImeta)) + return true; + // Strip mention/channel wrappers that Tiptap would misread as bold. const html = event.clipboardData?.getData("text/html"); if (html && hasMentionClipboardHtml(html)) { const cleanHtml = normalizeMentionClipboardHtml(html); @@ -833,7 +831,7 @@ function MessageComposerImpl({ }, }, }); - }, [richText.editor, scrollComposerToBottom]); + }, [media.setPendingImeta, richText.editor, scrollComposerToBottom]); // ── Send button state ─────────────────────────────────────────────── const sendDisabled = React.useMemo( diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index a8cc91c88e..02173db35c 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -35,6 +35,7 @@ import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext" import { parseImetaTags } from "@/features/messages/lib/parseImeta"; import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; import { parseWaveMessageContent } from "@/features/messages/lib/waveMessage"; +import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedBy"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; @@ -205,6 +206,14 @@ export const MessageRow = React.memo( () => (message.tags ? parseImetaTags(message.tags) : undefined), [message.tags], ); + const snapshotSharedBy = React.useMemo( + () => + resolveSnapshotSharedBy( + { signerPubkey: message.signerPubkey }, + profiles, + ), + [message.signerPubkey, profiles], + ); const { customEmoji, emojiOnly } = useMessageEmoji( message.body, @@ -347,6 +356,7 @@ export const MessageRow = React.memo( mentionNames={mentionNames} mentionPubkeysByName={mentionPubkeysByName} searchQuery={searchQuery} + snapshotSharedBy={snapshotSharedBy} videoReviewContext={videoReviewContext} /> ); diff --git a/desktop/src/features/messages/ui/NewMessageScreen.tsx b/desktop/src/features/messages/ui/NewMessageScreen.tsx index 30b4d25db8..4e212c523f 100644 --- a/desktop/src/features/messages/ui/NewMessageScreen.tsx +++ b/desktop/src/features/messages/ui/NewMessageScreen.tsx @@ -1,4 +1,3 @@ -import { Bot, X } from "lucide-react"; import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -9,15 +8,9 @@ import { import type { Channel } from "@/shared/api/types"; import { useSendMessageMutation } from "@/features/messages/hooks"; import { getKeyboardSearchSelection } from "@/features/profile/lib/userCandidateSearch"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip"; import { useIdentityQuery } from "@/shared/api/hooks"; -import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import { - POOF_ORIGIN_CLASS, - POOF_TRIGGER_CLASS, -} from "@/shared/ui/PoofBurstProvider"; -import { PubKey } from "@/shared/ui/PubKey"; import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -344,95 +337,25 @@ export function NewMessageScreen() { To: {selectedUsers.map((user) => ( -
- - { - setInspectedRecipientPubkey(open ? user.pubkey : null); - }} - open={inspectedRecipientPubkey === user.pubkey} - > - - - - event.preventDefault()} - > -

- Verify {formatRecipientName(user)} -

- -

- {user.pubkey} -

-
-
- {user.isAgent ? ( - - ) : null} -
+ label={formatRecipientName(user)} + onInspectionOpenChange={(inspectionOpen) => { + setInspectedRecipientPubkey( + inspectionOpen ? user.pubkey : null, + ); + }} + onRemove={() => handleRemoveUser(user.pubkey)} + testIds={{ + chip: `new-dm-selected-${user.pubkey}`, + keyPopover: `new-dm-selected-key-popover-${user.pubkey}`, + name: `new-dm-recipient-name-${user.pubkey}`, + pubkey: `new-dm-selected-pubkey-${user.pubkey}`, + }} + user={user} + /> ))} void; + onRemove: () => void; + poofOnRemove?: boolean; + testIds?: SelectedRecipientChipTestIds; + user: UserSearchResult; +}) { + return ( +
+ + {inspectable ? ( + + + + + event.preventDefault()} + > +

Verify {label}

+ +

+ {user.pubkey} +

+
+
+ ) : ( + {label} + )} + {user.isAgent ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfileSnapshotExportDialog.tsx b/desktop/src/features/profile/ui/UserProfileSnapshotExportDialog.tsx index cdfe3cc199..003c12c812 100644 --- a/desktop/src/features/profile/ui/UserProfileSnapshotExportDialog.tsx +++ b/desktop/src/features/profile/ui/UserProfileSnapshotExportDialog.tsx @@ -16,10 +16,10 @@ export function UserProfileSnapshotExportDialog({ return ( { exportSnapshotMutation.mutate( diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index 399781ef08..e56c611b6c 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -27,6 +27,14 @@ export async function fetchMediaBytes( return new Uint8Array(bytes); } +/** Write text through the native clipboard after an asynchronous workflow. */ +export async function copyTextToSystemClipboard( + text: string, + html?: string, +): Promise { + await invokeTauri("copy_text_to_clipboard", { html, text }); +} + /** * Fetch an agent snapshot attachment in memory, verifying size, SHA-256, and * snapshot decode before returning the bytes. diff --git a/desktop/src/shared/api/tauriTeams.snapshotImport.test.mjs b/desktop/src/shared/api/tauriTeams.snapshotImport.test.mjs index 14872b1f65..a1616420dd 100644 --- a/desktop/src/shared/api/tauriTeams.snapshotImport.test.mjs +++ b/desktop/src/shared/api/tauriTeams.snapshotImport.test.mjs @@ -5,12 +5,11 @@ import { deriveImportPhase, getProfileSyncFailures, deriveImportToast, - buildTeamSnapshotSendArgs, } from "../../features/agents/ui/teamSnapshotImport.lib.ts"; -// Behavior tests for the team snapshot import dialog flow and the send-encode -// contract. These import and exercise the actual production functions used by -// TeamSnapshotImportDialog, useTeamActions, and TeamSnapshotSendDialog. +// Behavior tests for the team snapshot import dialog flow. These import and +// exercise the actual production functions used by TeamSnapshotImportDialog +// and useTeamActions. // ── Factories ──────────────────────────────────────────────────────────────── @@ -236,20 +235,3 @@ test("mixed_member_outcomes_memory_errors_and_profile_sync", () => { assert.match(toast.message, /2 memory entr/); assert.match(toast.message, /1 member failed to sync profile/); }); - -// ── buildTeamSnapshotSendArgs ──────────────────────────────────────────────── - -test("buildTeamSnapshotSendArgs_always_uses_png_format", () => { - const args = buildTeamSnapshotSendArgs("team-123", "core"); - assert.equal(args.id, "team-123"); - assert.equal(args.memoryLevel, "core"); - assert.equal(args.format, "png"); -}); - -test("buildTeamSnapshotSendArgs_passes_through_memory_level", () => { - for (const level of ["none", "core", "everything"]) { - const args = buildTeamSnapshotSendArgs("t", level); - assert.equal(args.memoryLevel, level); - assert.equal(args.format, "png", `format must be png for level "${level}"`); - } -}); diff --git a/desktop/src/shared/ui/PoofBurstProvider.tsx b/desktop/src/shared/ui/PoofBurstProvider.tsx index 3417b9dac2..b6651e5fae 100644 --- a/desktop/src/shared/ui/PoofBurstProvider.tsx +++ b/desktop/src/shared/ui/PoofBurstProvider.tsx @@ -2,6 +2,7 @@ import React, { type CSSProperties, useEffect, useRef, useState } from "react"; export const POOF_TRIGGER_CLASS = "buzz-poof-trigger"; export const POOF_ORIGIN_CLASS = "buzz-poof-origin"; +export const POOF_POINTER_ORIGIN_CLASS = "buzz-poof-pointer-origin"; export const POOF_DURATION_MS = 430; @@ -34,15 +35,23 @@ type PoofStyle = CSSProperties & { "--buzz-poof-y": string; }; -function getPoofOrigin(target: Element) { +type PoofPointer = { + x: number; + y: number; +}; + +function getPoofOrigin(target: Element, pointer?: PoofPointer) { const origin = target.closest(`.${POOF_ORIGIN_CLASS}`) ?? target; const rect = origin.getBoundingClientRect(); const baseSize = Math.min(Math.max(rect.width * 0.54, 104), 190); + const usesPointerOrigin = Boolean( + pointer && target.closest(`.${POOF_POINTER_ORIGIN_CLASS}`), + ); return { size: baseSize * POOF_SIZE_SCALE, - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, + x: usesPointerOrigin && pointer ? pointer.x : rect.left + rect.width / 2, + y: usesPointerOrigin && pointer ? pointer.y : rect.top + rect.height / 2, }; } @@ -148,14 +157,12 @@ export function PoofBurstProvider({ children }: { children: React.ReactNode }) { }, []); useEffect(() => { - function emitPoof(target: Element) { + function emitPoof(target: Element, pointer?: PoofPointer) { const id = idRef.current; idRef.current += 1; + const origin = getPoofOrigin(target, pointer); - setBursts((current) => [ - ...current.slice(-5), - { ...getPoofOrigin(target), id }, - ]); + setBursts((current) => [...current.slice(-5), { ...origin, id }]); playPoofSound(); const timeoutId = window.setTimeout(() => { @@ -187,7 +194,7 @@ export function PoofBurstProvider({ children }: { children: React.ReactNode }) { lastPointerDownTrigger = null; } }, POOF_DURATION_MS); - emitPoof(target); + emitPoof(target, { x: event.clientX, y: event.clientY }); } function handleDocumentClick(event: MouseEvent) { diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index f408c5aa31..f898a55094 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1340,8 +1340,13 @@ function createMarkdownComponents( href, ...props }: React.ComponentPropsWithoutRef<"a">) { - const { channels, imetaByUrl, onOpenMessageLink, onImportSnapshotFromUrl } = - useMarkdownRuntime(); + const { + channels, + imetaByUrl, + onOpenMessageLink, + onImportSnapshotFromUrl, + snapshotSharedBy, + } = useMarkdownRuntime(); if (!interactive) { return {children}; } @@ -1365,8 +1370,10 @@ function createMarkdownComponents( if (snapshotCard) { return ( (null); - useSmoothCorners(cardRef); - const [importState, setImportState] = React.useState({ phase: "idle", }); @@ -80,7 +90,9 @@ export function AgentSnapshotCard({ setImportState({ phase: "error", message: - err instanceof Error ? err.message : "Failed to fetch snapshot.", + err instanceof Error + ? err.message + : `Couldn’t load this ${snapshotKind}. Try again.`, }); } finally { inFlightRef.current = false; @@ -94,88 +106,109 @@ export function AgentSnapshotCard({ } const isFetching = importState.phase === "fetching"; + const SnapshotIcon = snapshotKind === "team" ? Users : Bot; const showThumb = !!thumb && !thumbError; + const formattedSize = + size == null + ? null + : size < 1024 + ? `${size} B` + : size < 1024 * 1024 + ? `${(size / 1024).toFixed(1)} KB` + : `${(size / (1024 * 1024)).toFixed(1)} MB`; + const metadata = [sharedBy ? `Shared by ${sharedBy}` : null, formattedSize] + .filter(Boolean) + .join(" · "); return ( -
- {/* Header row: icon + filename */} -
- - {showThumb ? ( + + {showThumb ? ( + <> + setThumbError(true)} /> - ) : ( - - )} - -
- - {filename} - - - {snapshotKind === "team" ? "Team snapshot" : "Agent snapshot"} - {size != null - ? ` · ${size < 1024 ? `${size} B` : size < 1024 * 1024 ? `${(size / 1024).toFixed(1)} KB` : `${(size / (1024 * 1024)).toFixed(1)} MB`}` - : ""} - -
-
- - {/* Error row */} - {importState.phase === "error" && ( -
+ ) : ( + + )} + + + - - {importState.message} -
- )} - - {/* Action row */} -
- - -
-
+ ? "Add team" + : "Add agent"} + + + ); } diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index bf9ef82616..63551024d2 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -32,6 +32,8 @@ export type MarkdownRuntime = { mentionPubkeysByName?: Record; onOpenChannel: (channelId: string) => void; onOpenMessageLink: (link: ParsedMessageLink) => void; + /** Display name of the message author sharing an agent snapshot. */ + snapshotSharedBy?: string; /** * Called by AgentSnapshotCard after a successful verified in-memory fetch. * The implementation should navigate to /agents and trigger the existing @@ -58,6 +60,8 @@ export type MarkdownProps = { mentionPubkeysByName?: Record; mediaInset?: boolean; searchQuery?: string; + /** Display name shown in shared-agent card metadata. */ + snapshotSharedBy?: string; videoReviewContext?: VideoReviewContext; /** * When set and the nudge payload's agent_pubkey matches, renders the diff --git a/desktop/src/shared/ui/markdownFileCard.test.mjs b/desktop/src/shared/ui/markdownFileCard.test.mjs index 4c13fcf23d..3fd9c035f0 100644 --- a/desktop/src/shared/ui/markdownFileCard.test.mjs +++ b/desktop/src/shared/ui/markdownFileCard.test.mjs @@ -94,6 +94,7 @@ test("resolveSnapshotCard: .agent.json with sha256 returns snapshot card", () => "", ); assert.ok(card !== null, "expected a snapshot card"); + assert.equal(card.displayName, "Analyst"); assert.equal(card.filename, "analyst.agent.json"); assert.equal(card.sha256, SHA256); assert.equal(card.snapshotKind, "agent"); @@ -107,10 +108,27 @@ test("resolveSnapshotCard: .agent.png with image/png mime returns snapshot card" "", ); assert.ok(card !== null); + assert.equal(card.displayName, "Analyst"); assert.equal(card.filename, "analyst.agent.png"); assert.equal(card.snapshotKind, "agent"); }); +test("resolveSnapshotCard: sender label is used as the agent name", () => { + const card = resolveSnapshotCard( + { + m: "image/png", + size: 2048, + filename: "animation-auditor.agent.png", + x: SHA256, + }, + PNG_URL, + "Animation Auditor", + ); + assert.ok(card !== null); + assert.equal(card.displayName, "Animation Auditor"); + assert.equal(card.filename, "animation-auditor.agent.png"); +}); + test("resolveSnapshotCard: .agent.json with octet-stream mime is eligible", () => { // JSON snapshots often arrive as application/octet-stream. const card = resolveSnapshotCard( @@ -283,7 +301,7 @@ test("resolveSnapshotCard: .team.png with image/png returns team snapshot card", ); assert.ok(card !== null); assert.equal(card.snapshotKind, "team"); - assert.ok(card.thumb != null, "PNG team card must have a thumb"); + assert.equal(card.thumb, undefined); }); test("resolveSnapshotCard: plain .team without suffix is not a snapshot", () => { diff --git a/desktop/src/shared/ui/markdownFileCard.ts b/desktop/src/shared/ui/markdownFileCard.ts index 18772cdf8d..05b4f677e5 100644 --- a/desktop/src/shared/ui/markdownFileCard.ts +++ b/desktop/src/shared/ui/markdownFileCard.ts @@ -25,6 +25,8 @@ export type ResolvedFileCard = { * can share the same routing path without mixing agent-only assumptions. */ export type ResolvedSnapshotCard = { + /** Sender-provided display label, with a filename-derived fallback. */ + displayName: string; href: string; filename: string; size?: number; @@ -40,6 +42,23 @@ export type ResolvedSnapshotCard = { thumb?: string; }; +function snapshotDisplayName(filename: string, childText: string): string { + const label = childText.trim(); + const labelIsSnapshotFilename = /\.agent\.(?:json|png)$/i.test(label); + if (label && !labelIsSnapshotFilename) return label; + + const filenameStem = filename + .replace(/\.agent\.(?:json|png)$/i, "") + .replace(/[-_]+/g, " ") + .trim(); + if (!filenameStem) return "Agent"; + + return filenameStem + .split(/\s+/) + .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) + .join(" "); +} + /** * Classify a markdown link as a snapshot candidate. * @@ -86,14 +105,15 @@ export function resolveSnapshotCard( const snapshotKind: "agent" | "team" = isJson || isPng ? "agent" : "team"; return { + displayName: snapshotDisplayName(filename, childText), href, filename, size: entry.size, sha256, snapshotKind, - // PNG snapshots use the attachment URL as the thumb source because it is - // the avatar card image. JSON snapshots use the generic icon. - thumb: isAnyPng ? rewriteRelayUrl(href) : undefined, + // Agent PNG snapshots carry the avatar card image. Team PNG snapshots use + // a transport placeholder, so render the team icon instead of that image. + thumb: isPng ? rewriteRelayUrl(href) : undefined, }; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5e0349e99b..9c5f3d17c3 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9426,6 +9426,9 @@ export function maybeInstallE2eTauriMocks() { return true; case "copy_image_to_clipboard": return; + case "copy_text_to_clipboard": + await navigator.clipboard.writeText((payload as { text: string }).text); + return; case "get_event": return handleGetEvent( payload as Parameters[0], @@ -9585,7 +9588,10 @@ export function maybeInstallE2eTauriMocks() { case "get_relay_self": if ((activeConfig?.mock?.relaySelfDelayMs ?? 0) > 0) { await new Promise((resolve) => - window.setTimeout(resolve, activeConfig?.mock?.relaySelfDelayMs), + window.setTimeout( + resolve, + activeConfig?.mock?.relaySelfDelayMs ?? 0, + ), ); } return activeConfig?.mock?.relaySelf ?? null; diff --git a/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts b/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts index 6d57c06ca0..0c665619e6 100644 --- a/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts +++ b/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts @@ -17,7 +17,7 @@ import { installMockBridge } from "../helpers/bridge"; const SHOTS = "test-results/screenshots-lifecycle"; // Persona ID used across the cascade-delete test. Must be a custom (non-builtin) -// persona so the actions menu renders the "Remove from My Agents" / delete path. +// persona so the actions menu renders the Delete path. const CASCADE_PERSONA_ID = "custom:test-cascade"; // Stable fake pubkeys for the two seeded managed agents. 64 lowercase hex chars @@ -96,9 +96,8 @@ test.describe("agent lifecycle feedback screenshots", () => { .getByRole("button", { name: "Open actions for Cascade Test Agent" }) .click(); - // For a custom (non-builtin) persona, the menu item is "Remove from My Agents" - // and it calls openDelete() → PersonaDeleteDialog opens. - await page.getByRole("menuitem", { name: "Remove from My Agents" }).click(); + // For a custom (non-builtin) persona, Delete opens PersonaDeleteDialog. + await page.getByRole("menuitem", { name: "Delete" }).click(); const dialog = page.getByRole("alertdialog"); await expect(dialog).toBeVisible({ timeout: 5_000 }); @@ -212,7 +211,7 @@ test.describe("agent lifecycle feedback screenshots", () => { await page .getByRole("button", { name: "Open actions for Cascade Test Agent" }) .click(); - await page.getByRole("menuitem", { name: "Remove from My Agents" }).click(); + await page.getByRole("menuitem", { name: "Delete" }).click(); const dialog = page.getByRole("alertdialog"); await expect(dialog).toBeVisible({ timeout: 5_000 }); @@ -250,7 +249,7 @@ test.describe("agent lifecycle feedback screenshots", () => { await page .getByRole("button", { name: "Open actions for Cascade Test Agent" }) .click(); - await page.getByRole("menuitem", { name: "Remove from My Agents" }).click(); + await page.getByRole("menuitem", { name: "Delete" }).click(); const dialog = page.getByRole("alertdialog"); await expect(dialog).toBeVisible({ timeout: 5_000 }); diff --git a/desktop/tests/e2e/agent-snapshot-recipient.spec.ts b/desktop/tests/e2e/agent-snapshot-recipient.spec.ts index 5e8b54d8bc..a20c649df4 100644 --- a/desktop/tests/e2e/agent-snapshot-recipient.spec.ts +++ b/desktop/tests/e2e/agent-snapshot-recipient.spec.ts @@ -3,13 +3,25 @@ * * These tests exercise the AgentSnapshotCard rendered in a message timeline * when an .agent.json or .agent.png attachment is detected, and the full - * Import agent → preview → confirm flow. + * Add agent → preview → confirm flow. */ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; type CommandLogEntry = { command: string; payload: unknown }; +function hasVisibleBoxShadow(boxShadow: string) { + if (boxShadow === "none") return false; + + const colors = [...boxShadow.matchAll(/rgba?\(([^)]+)\)/gu)]; + if (colors.length === 0) return true; + + return colors.some(([, channels]) => { + const values = channels.split(",").map((value) => value.trim()); + return values.length < 4 || Number(values[3]) > 0; + }); +} + async function readCommandLog(page: import("@playwright/test").Page) { return page.evaluate( () => @@ -129,16 +141,81 @@ test("recipient_timeline_renders_agent_snapshot_card_not_file_card", async ({ // The sent attachment must render as AgentSnapshotCard. const card = page.getByTestId("agent-snapshot-card").last(); await expect(card).toBeVisible({ timeout: 5000 }); + await expect(card).toHaveAttribute("data-slot", "attachment"); + + // Show the agent name without the snapshot file extension. + const title = card.locator('[data-slot="attachment-title"]'); + await expect(title).toBeVisible(); + await expect(title).toHaveText("E2e Agent"); + await expect(card).not.toContainText("e2e-agent.agent.json"); - // Must show filename. - await expect(card).toContainText("e2e-agent.agent.json"); + const mediaBox = await card + .locator('[data-slot="attachment-media"]') + .boundingBox(); + expect(mediaBox?.width).toBe(mediaBox?.height); - // Must show "Agent snapshot" label (untrusted until decode). - await expect(card).toContainText("Agent snapshot"); + // Metadata names the sharer and keeps the file size. + await expect(card).toContainText("Shared by npub1mock... · 1.2 KB"); + await expect(card).not.toContainText("Agent snapshot"); // Both actions must be present. await expect(card.getByTestId("agent-snapshot-card-import")).toBeVisible(); await expect(card.getByTestId("agent-snapshot-card-download")).toBeVisible(); + await expect(card.locator('[data-slot="attachment-action"]')).toHaveCount(2); + const actions = card.locator('[data-slot="attachment-actions"] button'); + await expect(actions.nth(0)).toHaveAccessibleName("Download E2e Agent"); + await expect(actions.nth(0)).toHaveText(""); + await expect(actions.nth(1)).toHaveText("Add agent"); + + const addAgentButton = actions.nth(1); + const restingButtonStyle = await addAgentButton.evaluate((element) => { + const style = getComputedStyle(element); + return { + backgroundColor: style.backgroundColor, + boxShadow: style.boxShadow, + color: style.color, + }; + }); + expect(restingButtonStyle.color).not.toBe(restingButtonStyle.backgroundColor); + expect(hasVisibleBoxShadow(restingButtonStyle.boxShadow)).toBe(false); + + await addAgentButton.hover(); + await expect + .poll(async () => + hasVisibleBoxShadow( + await addAgentButton.evaluate( + (element) => getComputedStyle(element).boxShadow, + ), + ), + ) + .toBe(false); + + const [contentBox, downloadBox, addAgentBox] = await Promise.all([ + card.locator('[data-slot="attachment-content"]').boundingBox(), + actions.nth(0).boundingBox(), + addAgentButton.boundingBox(), + ]); + expect( + (downloadBox?.x ?? 0) - ((contentBox?.x ?? 0) + (contentBox?.width ?? 0)), + ).toBeGreaterThanOrEqual(28); + expect( + (addAgentBox?.x ?? 0) - ((downloadBox?.x ?? 0) + (downloadBox?.width ?? 0)), + ).toBeGreaterThanOrEqual(8); + + const [titleElement, descriptionElement] = await Promise.all([ + title.elementHandle(), + card.locator('[data-slot="attachment-description"]').elementHandle(), + ]); + expect( + await titleElement?.evaluate( + (element) => element.scrollWidth <= element.clientWidth, + ), + ).toBe(true); + expect( + await descriptionElement?.evaluate( + (element) => element.scrollWidth <= element.clientWidth, + ), + ).toBe(true); // Generic FileCard must NOT be present for this attachment. await expect(page.getByTestId("file-card")).toHaveCount(0); @@ -163,7 +240,7 @@ test("recipient_download_invokes_download_file_only", async ({ page }) => { expect(fetchSnapshotCmds.length).toBe(0); }); -// ── Import agent: fetch → navigate to agents → open preview ────────────────── +// ── Add agent: fetch → navigate to agents → open preview ───────────────────── test("recipient_import_navigates_to_agents_and_opens_preview", async ({ page, @@ -173,7 +250,7 @@ test("recipient_import_navigates_to_agents_and_opens_preview", async ({ const card = page.getByTestId("agent-snapshot-card").last(); await expect(card).toBeVisible({ timeout: 5000 }); - // Click Import agent. + // Click Add agent. await card.getByTestId("agent-snapshot-card-import").click(); // fetch_snapshot_bytes must have been called. @@ -183,7 +260,7 @@ test("recipient_import_navigates_to_agents_and_opens_preview", async ({ expect(fetchCmds.length).toBeGreaterThanOrEqual(1); }).toPass({ timeout: 5000 }); - // download_file must NOT have been called for Import. + // download_file must NOT have been called for Add agent. const log = await readCommandLog(page); const downloadCmds = log.filter((e) => e.command === "download_file"); expect(downloadCmds.length).toBe(0); @@ -257,7 +334,7 @@ test("recipient_double_click_import_opens_one_preview", async ({ page }) => { await expect(card).toBeVisible({ timeout: 5000 }); const importBtn = card.getByTestId("agent-snapshot-card-import"); - // Click Import agent. + // Click Add agent. await importBtn.click(); // Import dialog must open exactly once (not duplicated by rapid clicks). diff --git a/desktop/tests/e2e/agent-snapshot-send.spec.ts b/desktop/tests/e2e/agent-snapshot-send.spec.ts deleted file mode 100644 index ce1e367aca..0000000000 --- a/desktop/tests/e2e/agent-snapshot-send.spec.ts +++ /dev/null @@ -1,1241 +0,0 @@ -import { expect, test } from "@playwright/test"; - -import { - installMockBridge, - createMockAgentMemoryListing, -} from "../helpers/bridge"; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -type CommandLogEntry = { command: string; payload: unknown }; - -async function readCommandLog(page: import("@playwright/test").Page) { - return page.evaluate(() => { - return ( - ( - window as Window & { - __BUZZ_E2E_COMMAND_LOG__?: CommandLogEntry[]; - } - ).__BUZZ_E2E_COMMAND_LOG__ ?? [] - ); - }); -} - -async function gotoAgentsPage(page: import("@playwright/test").Page) { - await page.goto("/"); - await page.getByTestId("open-agents-view").click(); -} - -// Seeded persona ID used across tests. -const ANALYST_PERSONA_ID = "test-analyst"; -const ANALYST_PUBKEY = - "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; - -const MOCK_UPLOAD_DESCRIPTOR = { - url: `https://mock.relay/media/${"a".repeat(64)}.png`, - sha256: "a".repeat(64), - size: 1234, - type: "image/png", - uploaded: Math.floor(Date.now() / 1000), - filename: "analyst.agent.png", -}; - -// ── Destination picker: channel/DM visibility ───────────────────────────────── - -test("snapshot_send_dialog_shows_joined_channels_and_dms", async ({ page }) => { - await installMockBridge(page, { - personas: [ - { - id: ANALYST_PERSONA_ID, - displayName: "Analyst", - systemPrompt: "You are an analyst.", - }, - ], - managedAgents: [ - { - pubkey: ANALYST_PUBKEY, - name: "Analyst", - personaId: ANALYST_PERSONA_ID, - }, - ], - uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR], - }); - await gotoAgentsPage(page); - - await page.getByLabel("Open actions for Analyst").click(); - await page.getByRole("menuitem", { name: "Export snapshot" }).click(); - // Pick the "Send in Buzz" option (if shown via format modal) or directly - // expect the dialog. The export dialog offers save vs send — click Send in Buzz. - const sendBtn = page.getByRole("button", { name: "Send in Buzz" }); - if (await sendBtn.isVisible()) { - await sendBtn.click(); - } - - await expect(page.getByTestId("agent-snapshot-send-dialog")).toBeVisible(); - - const list = page.getByTestId("agent-snapshot-send-channel-list"); - await expect(list).toBeVisible(); - - // Joined streams (general, random) must appear. - await expect(list).toContainText("general"); - await expect(list).toContainText("random"); - - // DMs (alice-tyler, bob-tyler) must appear. - await expect(list).toContainText("alice-tyler"); - await expect(list).toContainText("bob-tyler"); -}); - -test("snapshot_send_dialog_excludes_forum_archived_and_moderation_dm", async ({ - page, -}) => { - // RELAY_SELF_PUBKEY matches alice (the DM peer) so alice-tyler becomes a moderation DM. - const ALICE_PUBKEY = - "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; - await installMockBridge(page, { - personas: [ - { - id: ANALYST_PERSONA_ID, - displayName: "Analyst", - systemPrompt: "You are an analyst.", - }, - ], - managedAgents: [ - { - pubkey: ANALYST_PUBKEY, - name: "Analyst", - personaId: ANALYST_PERSONA_ID, - }, - ], - relaySelf: ALICE_PUBKEY, - uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR], - }); - await gotoAgentsPage(page); - - await page.getByLabel("Open actions for Analyst").click(); - await page.getByRole("menuitem", { name: "Export snapshot" }).click(); - const sendBtn = page.getByRole("button", { name: "Send in Buzz" }); - if (await sendBtn.isVisible()) await sendBtn.click(); - - await expect(page.getByTestId("agent-snapshot-send-dialog")).toBeVisible(); - - const list = page.getByTestId("agent-snapshot-send-channel-list"); - await expect(list).toBeVisible(); - - // The DM whose only other participant is ALICE_PUBKEY (= relaySelf) must - // NOT appear — it is a moderation DM. - await expect(list.getByText("alice-tyler")).toHaveCount(0); - - // Forums must not appear (watercooler and announcements are seeded forums). - await expect(list).not.toContainText("watercooler"); - await expect(list).not.toContainText("announcements"); - - // Non-member channels must not appear (design and sales exclude the mock user). - await expect(list).not.toContainText("design"); - await expect(list).not.toContainText("sales"); -}); - -// ── Moderation-DM fail-closed race: DMs withheld during relay-self loading ──── - -test("snapshot_send_moderation_dm_not_selectable_during_relay_self_loading", async ({ - page, -}) => { - // ALICE_PUBKEY is relaySelf but the response is delayed — alice-tyler must - // NOT appear in the picker while get_relay_self is in-flight. - const ALICE_PUBKEY = - "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; - await installMockBridge(page, { - personas: [ - { - id: ANALYST_PERSONA_ID, - displayName: "Analyst", - systemPrompt: "You are an analyst.", - }, - ], - managedAgents: [ - { - pubkey: ANALYST_PUBKEY, - name: "Analyst", - personaId: ANALYST_PERSONA_ID, - }, - ], - relaySelf: ALICE_PUBKEY, - // Delay get_relay_self so we can observe the fail-closed window. - relaySelfDelayMs: 2000, - uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR], - }); - await gotoAgentsPage(page); - - await page.getByLabel("Open actions for Analyst").click(); - await page.getByRole("menuitem", { name: "Export snapshot" }).click(); - const sendBtn = page.getByRole("button", { name: "Send in Buzz" }); - if (await sendBtn.isVisible()) await sendBtn.click(); - - await expect(page.getByTestId("agent-snapshot-send-dialog")).toBeVisible(); - - const list = page.getByTestId("agent-snapshot-send-channel-list"); - await expect(list).toBeVisible(); - - // While relay-self is loading, ALL DMs must be withheld (fail-closed). - // alice-tyler, bob-tyler, and the generic "DM" channel must not appear. - await expect(list.getByText("alice-tyler")).toHaveCount(0); - await expect(list.getByText("bob-tyler")).toHaveCount(0); - await expect(list.getByText("charlie")).toHaveCount(0); - - // Attempting to confirm must not be possible (no DM is selectable). - // Streams (general, random) are still available — confirm that. - await expect(list).toContainText("general"); - - // Select general and confirm — must proceed to done without any DM encode. - await list.getByText("general").click(); - await page.getByTestId("agent-snapshot-send-confirm").click(); - await expect(page.getByTestId("agent-snapshot-send-done")).toBeVisible({ - timeout: 8000, - }); - - // No DM channel was sent to. - const log = await readCommandLog(page); - const sendEntry = log.find((e) => e.command === "send_channel_message"); - const sendPayload = sendEntry?.payload as { channelId?: string } | undefined; - // general's id is the well-known seed value. - expect(sendPayload?.channelId).toBe("9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"); -}); - -// ── Config-only send flow: encode → upload → send, correct destination ──────── - -test("snapshot_send_config_only_calls_encode_upload_send_in_order", async ({ - page, -}) => { - await installMockBridge(page, { - personas: [ - { - id: ANALYST_PERSONA_ID, - displayName: "Analyst", - avatarUrl: "https://mock.relay/media/avatar.png", - systemPrompt: "You are an analyst.", - }, - ], - managedAgents: [ - { - pubkey: ANALYST_PUBKEY, - name: "Analyst", - personaId: ANALYST_PERSONA_ID, - }, - ], - uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR], - }); - await page.route("https://mock.relay/media/avatar.png", (route) => - route.fulfill({ - body: Buffer.from([0x89, 0x50, 0x4e, 0x47]), - contentType: "image/png", - headers: { "access-control-allow-origin": "*" }, - }), - ); - await gotoAgentsPage(page); - - await page.getByLabel("Open actions for Analyst").click(); - await page.getByRole("menuitem", { name: "Export snapshot" }).click(); - const sendBtn = page.getByRole("button", { name: "Send in Buzz" }); - if (await sendBtn.isVisible()) await sendBtn.click(); - - await expect(page.getByTestId("agent-snapshot-send-dialog")).toBeVisible(); - - // Select #general. - await page - .getByTestId("agent-snapshot-send-channel-list") - .getByText("general") - .click(); - - // Confirm send. - await page.getByTestId("agent-snapshot-send-confirm").click(); - - // Dialog transitions: progress → done. - await expect(page.getByTestId("agent-snapshot-send-progress")).toBeVisible(); - await expect(page.getByTestId("agent-snapshot-send-done")).toBeVisible({ - timeout: 8000, - }); - - // Verify command order: encode → upload → send_channel_message. - const log = await readCommandLog(page); - const relevantCommands = log - .filter((e) => - [ - "encode_agent_snapshot_for_send", - "upload_media_bytes", - "send_channel_message", - ].includes(e.command), - ) - .map((e) => e.command); - - expect(relevantCommands).toEqual([ - "encode_agent_snapshot_for_send", - "upload_media_bytes", - "send_channel_message", - ]); - - // Confirm send_channel_message targeted #general (its id from the seed). - const sendEntry = log.find((e) => e.command === "send_channel_message"); - expect(sendEntry).toBeTruthy(); - const sendPayload = sendEntry?.payload as - | { channelId?: string; mediaTags?: string[][] } - | undefined; - // The general channel id is fixed in the e2eBridge seed. - expect(sendPayload?.channelId).toBe("9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"); - - // The imeta tag must carry exact URL / MIME / hash / size / filename from - // the mock upload descriptor — proves the descriptor is threaded through - // buildImetaTags without field drops or substitutions. - const imeta = sendPayload?.mediaTags?.[0]; - expect(imeta).toBeDefined(); - const sha = "a".repeat(64); - const expectedUrl = `https://mock.relay/media/${sha}.png`; - expect(imeta).toContain(`url ${expectedUrl}`); - expect(imeta).toContain("m image/png"); - expect(imeta).toContain(`x ${sha}`); - expect(imeta).toContain("size 1234"); - // The filename in the imeta comes from the encode payload's fileName — the - // controller sets descriptorWithFilename.filename = fileName (the file produced - // by encode_agent_snapshot_for_send), which the bridge hardcodes as - // "e2e-agent.agent.png". - expect(imeta).toContain("filename e2e-agent.agent.png"); - - // Send-in-Buzz always encodes PNG so the attachment itself provides the - // avatar thumbnail. - const encodeEntry = log.find( - (e) => e.command === "encode_agent_snapshot_for_send", - ); - expect(encodeEntry).toBeTruthy(); - const encodePayload = encodeEntry?.payload as - | { format?: string; avatarPngDataUrl?: string } - | undefined; - expect(encodePayload?.format).toBe("png"); - expect(encodePayload?.avatarPngDataUrl).toEqual( - expect.stringMatching(/^data:image\/png;base64,/), - ); - - // Close the dialog and navigate to #general to verify the AgentSnapshotCard renders. - await page.getByRole("button", { name: "Close" }).click(); - await page.getByTestId("channel-general").click(); - - // The sent attachment must appear as an AgentSnapshotCard (not a generic - // FileCard) with the exact filename that the encode step produced. - const snapshotCard = page.getByTestId("agent-snapshot-card").last(); - await expect(snapshotCard).toBeVisible({ timeout: 5000 }); - await expect(snapshotCard).toContainText("e2e-agent.agent.png"); -}); - -// ── Memory-bearing flow: gate stops before encode/upload/send ───────────────── - -test("snapshot_send_memory_gate_stops_before_encode_on_cancel", async ({ - page, -}) => { - await installMockBridge(page, { - personas: [ - { - id: ANALYST_PERSONA_ID, - displayName: "Analyst", - systemPrompt: "You are an analyst.", - }, - ], - managedAgents: [ - { - pubkey: ANALYST_PUBKEY, - name: "Analyst", - personaId: ANALYST_PERSONA_ID, - status: "running", - }, - ], - agentMemory: createMockAgentMemoryListing(), - uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR], - }); - await gotoAgentsPage(page); - - await page.getByLabel("Open actions for Analyst").click(); - await page.getByRole("menuitem", { name: "Export snapshot" }).click(); - - // Select memory level = "core" using the accessible label on the radio input. - // The radio inputs are wrapped in