From b1a7fadd86310123cbaabade5565a3e06d4e2e6e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 14 Jul 2026 15:04:09 +0100 Subject: [PATCH 01/28] Polish agent sharing and snapshots --- desktop/playwright.config.ts | 1 - .../agents/ui/AgentSnapshotExportDialog.tsx | 299 ++-- .../agents/ui/AgentSnapshotSendDialog.tsx | 1 + desktop/src/features/agents/ui/AgentsView.tsx | 25 +- .../features/agents/ui/PersonaActionsMenu.tsx | 26 +- .../features/agents/ui/PersonaShareDialog.tsx | 353 ++++- .../agents/ui/PersonaShareRecipients.tsx | 293 ++++ .../features/agents/ui/SnapshotOptionMenu.tsx | 75 + .../agents/ui/UnifiedAgentsSection.tsx | 5 +- .../agents/ui/snapshotAvatarPng.test.mjs | 11 +- .../features/agents/ui/snapshotAvatarPng.ts | 32 +- .../features/agents/ui/usePersonaActions.ts | 67 +- .../agents/ui/useSnapshotSendController.ts | 19 +- .../messages/lib/imetaMediaMarkdown.test.mjs | 14 + .../messages/lib/imetaMediaMarkdown.ts | 9 +- .../src/features/messages/ui/MessageRow.tsx | 1 + .../ui/UserProfileSnapshotExportDialog.tsx | 2 +- desktop/src/shared/ui/markdown.tsx | 15 +- .../shared/ui/markdown/AgentSnapshotCard.tsx | 184 ++- desktop/src/shared/ui/markdown/types.ts | 4 + .../src/shared/ui/markdownFileCard.test.mjs | 18 + desktop/src/shared/ui/markdownFileCard.ts | 20 + .../e2e/agent-lifecycle-feedback.spec.ts | 11 +- .../e2e/agent-snapshot-recipient.spec.ts | 95 +- desktop/tests/e2e/agent-snapshot-send.spec.ts | 1241 ----------------- desktop/tests/e2e/agents.spec.ts | 504 ++++++- 26 files changed, 1612 insertions(+), 1713 deletions(-) create mode 100644 desktop/src/features/agents/ui/PersonaShareRecipients.tsx create mode 100644 desktop/src/features/agents/ui/SnapshotOptionMenu.tsx delete mode 100644 desktop/tests/e2e/agent-snapshot-send.spec.ts 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/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 index a705b540a5..33f7bd9ae6 100644 --- a/desktop/src/features/agents/ui/AgentSnapshotSendDialog.tsx +++ b/desktop/src/features/agents/ui/AgentSnapshotSendDialog.tsx @@ -170,6 +170,7 @@ export function AgentSnapshotSendDialog({ avatarPngDataUrl: await resolveSnapshotAvatarPng(persona.avatarUrl), }), destination.id, + persona.displayName, ); // Phase transitions (done/error) are driven by beginSend via setState // inside the controller; the mirror effect above keeps `step` in sync. diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 15ccc43d48..0d7e951a90 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -146,7 +146,6 @@ export function AgentsView() { onDuplicatePersona={personas.openDuplicate} onEditPersona={personas.openEdit} onSharePersona={personas.openShare} - onExportPersonaSnapshot={personas.openExportSnapshot} onDeactivatePersona={(persona) => { void personas.handleSetActive(persona, false, "library"); }} @@ -289,23 +288,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 +302,14 @@ export function AgentsView() { } }} open={personas.personaToShare !== null} - persona={personas.personaToShare} + persona={personas.personaToShare.persona} /> ) : null} {personas.personaToExportSnapshot ? ( { if (personas.personaToExportSnapshot) { diff --git a/desktop/src/features/agents/ui/PersonaActionsMenu.tsx b/desktop/src/features/agents/ui/PersonaActionsMenu.tsx index e818e5cd52..5281d90916 100644 --- a/desktop/src/features/agents/ui/PersonaActionsMenu.tsx +++ b/desktop/src/features/agents/ui/PersonaActionsMenu.tsx @@ -1,9 +1,8 @@ import { - BookUser, CopyPlus, - Ellipsis, - FileDown, + EllipsisVertical, Pencil, + Share2, Trash2, } from "lucide-react"; @@ -24,20 +23,17 @@ export function PersonaActionsMenu({ onDuplicate, onEdit, onShare, - onExportSnapshot, onDeactivate, onDelete, }: { isActionPending: boolean; isPending: boolean; persona: AgentPersona; - /** Profile agent instance linked to this definition, if one exists. Used to - * supply a memory-source pubkey when the user exports with memory. */ + /** Profile agent instance linked to this definition, if one exists. */ linkedAgent: ManagedAgent | undefined; onDuplicate: (persona: AgentPersona) => 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..62ef6c722b 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -1,7 +1,23 @@ -import { BookUser, Download, X } from "lucide-react"; +import * as React from "react"; +import { ChevronRight, Download, Link2, Loader2, X } from "lucide-react"; +import { toast } from "sonner"; +import { useEncodeAgentSnapshotForSendMutation } from "@/features/agents/hooks"; +import { useOpenDmMutation } from "@/features/channels/hooks"; +import { + buildOutgoingMessage, + formatImetaMediaLine, +} from "@/features/messages/lib/imetaMediaMarkdown"; +import { useProfileQuery } from "@/features/profile/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import type { AgentPersona } from "@/shared/api/types"; +import { + sendChannelMessage, + uploadMediaBytes, + type BlobDescriptor, +} from "@/shared/api/tauri"; +import type { SnapshotMemoryLevel } from "@/shared/api/tauriPersonas"; +import type { AgentPersona, UserSearchResult } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -10,96 +26,319 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { Separator } from "@/shared/ui/separator"; -import { Switch } from "@/shared/ui/switch"; -import { PersonaAddedBy } from "./PersonaAddedBy"; +import { PersonaShareRecipients } from "./PersonaShareRecipients"; +import { SnapshotOptionMenu } from "./SnapshotOptionMenu"; +import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng"; type PersonaShareDialogProps = { - isCatalogVisible: boolean; isPending: boolean; - onCatalogVisibilityChange: (visible: boolean) => void; + linkedAgentPubkey: string | null; onExport: () => void; onOpenChange: (open: boolean) => void; open: boolean; persona: AgentPersona; }; +const SHARE_LEVELS: { value: SnapshotMemoryLevel; label: string }[] = [ + { value: "none", label: "Agent" }, + { value: "core", label: "Agent + core memory" }, + { value: "everything", label: "Agent + all memories" }, +]; + +function ShareLevelControl({ + ariaLabel, + className, + disabled, + hasLinkedAgent, + onOpenChange, + staticClassName, + testId, + value, + onChange, +}: { + ariaLabel: string; + className?: string; + disabled: boolean; + hasLinkedAgent: boolean; + onOpenChange?: (open: boolean) => void; + staticClassName?: string; + testId: string; + value: SnapshotMemoryLevel; + onChange: (level: SnapshotMemoryLevel) => void; +}) { + if (!hasLinkedAgent) { + return ( + + Agent + + ); + } + + return ( + onChange(nextValue as SnapshotMemoryLevel)} + options={SHARE_LEVELS} + testId={testId} + value={value} + /> + ); +} + export function PersonaShareDialog({ - isCatalogVisible, isPending, - onCatalogVisibilityChange, + linkedAgentPubkey, onExport, onOpenChange, open, persona, }: PersonaShareDialogProps) { - const switchId = `persona-share-catalog-${persona.id}`; + const encodeSnapshotMutation = useEncodeAgentSnapshotForSendMutation(); + const openDmMutation = useOpenDmMutation(); + const profileQuery = useProfileQuery(open); + const [selectedRecipients, setSelectedRecipients] = React.useState< + UserSearchResult[] + >([]); + const [isCopying, setIsCopying] = React.useState(false); + const [isSending, setIsSending] = React.useState(false); + const [linkShareLevel, setLinkShareLevel] = + React.useState("none"); + const [recipientShareLevel, setRecipientShareLevel] = + React.useState("none"); + + const isActionPending = isPending || isCopying || isSending; + const hasLinkedAgent = linkedAgentPubkey !== null; + const ownerDisplayName = + profileQuery.data?.displayName?.trim() || "Your account"; + + React.useEffect(() => { + if (open) { + setSelectedRecipients([]); + setIsCopying(false); + setIsSending(false); + setLinkShareLevel("none"); + setRecipientShareLevel("none"); + encodeSnapshotMutation.reset(); + } + }, [open, encodeSnapshotMutation.reset]); + + async function uploadSnapshot( + memoryLevel: SnapshotMemoryLevel, + ): Promise { + const encoded = await encodeSnapshotMutation.mutateAsync({ + id: persona.id, + memoryLevel: hasLinkedAgent ? memoryLevel : "none", + format: "png", + memorySourcePubkey: linkedAgentPubkey, + avatarPngDataUrl: await resolveSnapshotAvatarPng(persona.avatarUrl), + }); + const uploaded = await uploadMediaBytes( + encoded.fileBytes, + encoded.fileName, + ); + const { thumb: _thumb, ...uploadedWithoutThumb } = uploaded; + + return { + ...uploadedWithoutThumb, + filename: encoded.fileName, + }; + } + + async function handleCopyLink() { + if (isActionPending) return; + + setIsCopying(true); + try { + const uploaded = await uploadSnapshot(linkShareLevel); + await navigator.clipboard.writeText(uploaded.url); + toast.success("Link copied"); + } catch { + toast.error("Couldn’t copy link"); + } finally { + setIsCopying(false); + } + } + + async function handleSend() { + if (isActionPending || selectedRecipients.length === 0) return; + + setIsSending(true); + try { + const directMessage = await openDmMutation.mutateAsync({ + pubkeys: selectedRecipients.map((recipient) => recipient.pubkey), + }); + const uploaded = await uploadSnapshot(recipientShareLevel); + const outgoingMessage = buildOutgoingMessage("", [uploaded]); + await sendChannelMessage( + directMessage.id, + formatImetaMediaLine(uploaded, { label: persona.displayName }), + null, + outgoingMessage.mediaTags ?? [], + ); + toast.success(`Sent ${persona.displayName}`); + onOpenChange(false); + } catch { + toast.error("Couldn’t send agent. Try again."); + } finally { + setIsSending(false); + } + } return ( - -
- Catalog options -
+
+ + + Share {persona.displayName} + + + + + Close + + +
+
+ ( + + )} + selectedUsers={selectedRecipients} + /> - - - Close - -
-
- - -
-
- -
-

- {persona.displayName} -

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

Who has access

+
+ + + + + Anyone with a link + + +
+ +
+ +
+ + {ownerDisplayName} + + + (You) + +
+ + Owner + +
+
-
-
- - +
+

+ Anyone in this workspace with the link can duplicate and use + this agent. +

+
-
+
); diff --git a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx new file mode 100644 index 0000000000..c0ac727aaf --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx @@ -0,0 +1,293 @@ +import { Search, X } 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 { 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; + +function formatRecipientName(user: UserSearchResult) { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + truncatePubkey(user.pubkey) + ); +} + +export function PersonaShareRecipients({ + disabled, + onSelectionChange, + open, + renderEndControl, + selectedUsers, +}: { + disabled: boolean; + onSelectionChange: (users: UserSearchResult[]) => void; + open: boolean; + renderEndControl?: (onOpenChange: (open: boolean) => void) => React.ReactNode; + selectedUsers: UserSearchResult[]; +}) { + const [searchQuery, setSearchQuery] = React.useState(""); + const [isPickerOpen, setIsPickerOpen] = React.useState(false); + 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 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 && + !selectedPubkeys.has(pubkey) && + !isArchived(pubkey) + ); + }); + + return rankUserCandidatesBySearch({ + allowEmptyQuery: true, + candidates, + getLabel: formatRecipientName, + limit: 50, + query: deferredSearchQuery, + }); + }, [ + deferredSearchQuery, + 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 }); + }} + > +
+ {selectedUsers.length === 0 ? ( + + ) : null} + {selectedUsers.map((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()} + onOpenAutoFocus={(event) => event.preventDefault()} + sideOffset={6} + > +
+ {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/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/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/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index e5bac0024a..794a6d9f87 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -75,21 +75,6 @@ function readSharedCatalogPersonaIds(): string[] { } } -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 +96,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 +114,9 @@ export function usePersonaActions() { const [snapshotImportConfirmError, setSnapshotImportConfirmError] = React.useState(null); const [isCatalogDialogOpen, setIsCatalogDialogOpen] = React.useState(false); - const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState< - string[] - >(readSharedCatalogPersonaIds); + const [sharedCatalogPersonaIds] = React.useState( + readSharedCatalogPersonaIds, + ); const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState< string | null >(null); @@ -385,22 +372,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 +416,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,12 +458,9 @@ export function usePersonaActions() { openCatalog, openDelete, openShare, - openExportSnapshot, - openShareExportSnapshot, personaToExportSnapshot, setPersonaToExportSnapshot, handleExportSnapshot, - setPersonaCatalogVisibility, sharedCatalogPersonaIdSet, clearFeedback, snapshotImportState, diff --git a/desktop/src/features/agents/ui/useSnapshotSendController.ts b/desktop/src/features/agents/ui/useSnapshotSendController.ts index 2a16312bef..afb7cc9623 100644 --- a/desktop/src/features/agents/ui/useSnapshotSendController.ts +++ b/desktop/src/features/agents/ui/useSnapshotSendController.ts @@ -19,7 +19,10 @@ 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 { + buildOutgoingMessage, + formatImetaMediaLine, +} from "@/features/messages/lib/imetaMediaMarkdown"; import { channelsQueryKey, useChannelsQuery } from "@/features/channels/hooks"; import { isModerationDm } from "@/features/moderation/lib/moderationDm"; import { @@ -360,6 +363,7 @@ export type UseSnapshotSendControllerResult = { beginSend: ( encodeFn: () => Promise<{ fileBytes: number[]; fileName: string }>, channelId: string, + attachmentLabel?: string, ) => Promise; /** Set state to error with a message (for pre-send gate failures). */ setErrorState: (message: string) => void; @@ -452,6 +456,7 @@ export function useSnapshotSendController(): UseSnapshotSendControllerResult { async function beginSend( encodeFn: () => Promise<{ fileBytes: number[]; fileName: string }>, channelId: string, + attachmentLabel?: string, ): Promise { return runGuardedSend(guardRef.current, { encodeFn, @@ -462,7 +467,17 @@ export function useSnapshotSendController(): UseSnapshotSendControllerResult { uploadFn: (bytes, filename) => uploadMediaBytes(bytes, filename), sendFn: (args) => sendMutation.mutateAsync(args), setStateFn: setState, - buildMessageFn: (descriptor) => buildOutgoingMessage("", [descriptor]), + buildMessageFn: (descriptor) => { + const message = buildOutgoingMessage("", [descriptor]); + return attachmentLabel?.trim() + ? { + ...message, + content: formatImetaMediaLine(descriptor, { + label: attachmentLabel, + }), + } + : message; + }, }); } diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index bac92361d2..4fa81fd7ab 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -139,6 +139,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. diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index 5fd028bc23..340828765f 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -240,7 +240,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 +255,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. diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index a8cc91c88e..5cd19f45a4 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -347,6 +347,7 @@ export const MessageRow = React.memo( mentionNames={mentionNames} mentionPubkeysByName={mentionPubkeysByName} searchQuery={searchQuery} + snapshotSharedBy={message.author} videoReviewContext={videoReviewContext} /> ); 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/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; @@ -95,87 +107,107 @@ export function AgentSnapshotCard({ const isFetching = importState.phase === "fetching"; 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..4869d7cb19 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( diff --git a/desktop/src/shared/ui/markdownFileCard.ts b/desktop/src/shared/ui/markdownFileCard.ts index 18772cdf8d..47e3ac532c 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,6 +105,7 @@ export function resolveSnapshotCard( const snapshotKind: "agent" | "team" = isJson || isPng ? "agent" : "team"; return { + displayName: snapshotDisplayName(filename, childText), href, filename, size: entry.size, 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