Polish agent and team sharing and snapshots (#1852)

This commit is contained in:
klopez4212
2026-07-15 18:19:03 +01:00
committed by GitHub
parent c5996e13a4
commit 2f3fa586f3
54 changed files with 4212 additions and 3402 deletions

View File

@@ -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",

View File

@@ -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<String>,
app: tauri::AppHandle,
) -> Result<(), String> {
let (tx, rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(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.

View File

@@ -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,

View File

@@ -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");
},
}),
);
});

View File

@@ -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<Storage, "removeItem"> | 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.
}
}

View File

@@ -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<SnapshotMemoryLevel>("none");
const [format, setFormat] = React.useState<SnapshotFormat>("json");
const [sendOpen, setSendOpen] = React.useState(false);
const [format, setFormat] = React.useState<SnapshotFormat>("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 (
<>
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
className="max-w-md"
data-testid="agent-snapshot-export-dialog"
showCloseButton={false}
>
<DialogHeader className="space-y-0">
<div className="flex items-center justify-between gap-4">
<DialogTitle>Export agent snapshot</DialogTitle>
<div className="flex items-center gap-2">
{/* Primary: Send in Buzz */}
<Button
data-testid="agent-snapshot-send-in-buzz"
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
className="max-w-md"
data-testid="agent-snapshot-export-dialog"
showCloseButton={false}
>
<DialogHeader className="space-y-0">
<DialogTitle className="truncate">Export {agentName}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-1">
<div className="flex min-h-8 items-center justify-between gap-4">
<span className="flex min-w-0 items-center gap-2 text-sm font-medium">
<Brain className="h-4 w-4 shrink-0 text-muted-foreground" />
Memories
</span>
{hasLinkedAgent ? (
<SnapshotOptionMenu
ariaLabel="Memories"
className="font-medium text-foreground"
disabled={isPending}
onClick={() => setSendOpen(true)}
size="sm"
type="button"
variant="default"
onValueChange={(value) =>
setMemoryLevel(value as SnapshotMemoryLevel)
}
options={MEMORY_LEVELS}
testId="agent-snapshot-memory-trigger"
value={memoryLevel}
/>
) : (
<span
className="inline-flex h-8 w-auto items-center justify-end px-2 text-sm font-medium"
data-testid="agent-snapshot-memory-value"
>
<Send className="h-4 w-4" />
Send in Buzz
</Button>
{/* Secondary: Save file */}
<Button
data-testid="agent-snapshot-export-confirm"
disabled={isPending}
onClick={() => onSaveFile(memoryLevel, format)}
size="sm"
type="button"
variant="outline"
>
<Download className="h-4 w-4" />
Save file
</Button>
<DialogClose asChild>
<Button
disabled={isPending}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
</DialogClose>
</div>
</div>
</DialogHeader>
<Separator />
<div className="space-y-4 py-1">
{/* Agent identity */}
<p className="text-sm text-muted-foreground">
Exporting{" "}
<span className="font-medium text-foreground">
{persona.displayName}
</span>{" "}
as a portable snapshot. The recipient imports it as a <em>new</em>{" "}
agent with fresh keys — identity never travels.
</p>
{/* Memory level picker */}
<div className="space-y-2">
<p className="text-sm font-medium">Memory to include</p>
<div className="space-y-1">
{MEMORY_LEVELS.map(({ value, label, description }) => {
const memoryDisabled = !hasLinkedAgent && value !== "none";
return (
<label
className={`flex items-start gap-3 rounded-md px-3 py-2 ${memoryDisabled ? "cursor-not-allowed opacity-40" : "cursor-pointer hover:bg-muted"}`}
key={value}
>
<input
checked={memoryLevel === value}
className="mt-0.5 shrink-0"
disabled={memoryDisabled}
name="memory-level"
onChange={() =>
!memoryDisabled && setMemoryLevel(value)
}
type="radio"
value={value}
/>
<div>
<p className="text-sm font-medium leading-none">
{label}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{description}
</p>
</div>
</label>
);
})}
</div>
{!hasLinkedAgent ? (
<p className="px-3 text-xs text-muted-foreground">
Memory export requires a running agent instance. Start this
definition to enable memory levels.
</p>
) : null}
Agent only
</span>
)}
</div>
{/* Plaintext memory warning */}
{showMemoryWarning ? (
<div
className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400"
data-testid="agent-snapshot-memory-warning"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Memory is stored as <strong>plaintext</strong> in the
snapshot. Only share it with people you trust.
</p>
</div>
) : null}
{/* Format picker */}
<div className="space-y-2">
<p className="text-sm font-medium">File format</p>
<div className="flex gap-3">
<label className="flex cursor-pointer items-center gap-2">
<input
checked={format === "json"}
name="snapshot-format"
onChange={() => setFormat("json")}
type="radio"
value="json"
/>
<span className="text-sm">.agent.json</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
checked={format === "png"}
name="snapshot-format"
onChange={() => setFormat("png")}
type="radio"
value="png"
/>
<span className="text-sm">.agent.png</span>
</label>
</div>
<p className="text-xs text-muted-foreground">
Applies to saved files; snapshots shared in Buzz always use
.agent.png. PNG exports include memory when selected.
</p>
<div className="flex min-h-8 items-center justify-between gap-4">
<span className="flex min-w-0 items-center gap-2 text-sm font-medium">
<FileType2 className="h-4 w-4 shrink-0 text-muted-foreground" />
File format
</span>
<SnapshotOptionMenu
ariaLabel="File format"
className="font-medium text-foreground"
disabled={isPending}
onValueChange={(value) => setFormat(value as SnapshotFormat)}
options={FORMAT_OPTIONS}
testId="agent-snapshot-format-trigger"
value={format}
/>
</div>
</div>
</DialogContent>
</Dialog>
{/* Send-in-Buzz destination picker — opened as a secondary dialog */}
{sendOpen ? (
<AgentSnapshotSendDialog
linkedAgentPubkey={linkedAgentPubkey}
memoryLevel={memoryLevel}
open={sendOpen}
persona={persona}
onOpenChange={(open) => {
setSendOpen(open);
}}
onSent={() => {
setSendOpen(false);
onOpenChange(false);
}}
/>
) : null}
</>
<AnimatePresence initial={false}>
{showMemoryWarning ? (
<motion.div
animate={{ height: "auto", opacity: 1 }}
className="overflow-hidden"
data-testid="agent-snapshot-memory-warning-motion"
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
key="agent-snapshot-memory-warning"
transition={modalResizeTransition}
>
<div
className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400"
data-testid="agent-snapshot-memory-warning"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Memory is stored as <strong>plaintext</strong> in the
snapshot. Only share it with people you trust.
</p>
</div>
</motion.div>
) : null}
</AnimatePresence>
<div
className="flex items-center justify-end gap-2 pt-2"
data-testid="agent-snapshot-export-footer"
>
<DialogClose asChild>
<Button
disabled={isPending}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
</DialogClose>
<Button
data-testid="agent-snapshot-export-confirm"
disabled={isPending}
onClick={() => onSaveFile(memoryLevel, format)}
size="sm"
type="button"
>
<Download className="h-4 w-4" />
Export
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<DialogStep>("pick");
const [selectedChannel, setSelectedChannel] =
React.useState<ResolvedChannel | null>(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 (
<Dialog onOpenChange={handleClose} open={open}>
<DialogContent
aria-describedby={undefined}
className="max-w-md"
data-testid="agent-snapshot-send-dialog"
showCloseButton={false}
>
<DialogHeader className="space-y-0">
<div className="flex items-center justify-between gap-4">
<DialogTitle>
{step === "done"
? "Snapshot sent"
: step === "memgate"
? "Confirm memory share"
: "Send snapshot in Buzz"}
</DialogTitle>
{step !== "progress" && step !== "done" && step !== "error" ? (
<div className="flex items-center gap-2">
{step === "pick" ? (
<Button
data-testid="agent-snapshot-send-confirm"
disabled={selectedChannel === null || isInProgress}
onClick={handlePickConfirm}
size="sm"
type="button"
variant="default"
>
<Send className="h-4 w-4" />
{hasMemory ? "Next" : "Send"}
</Button>
) : (
// memgate step
<Button
data-testid="agent-snapshot-send-memgate-confirm"
disabled={isInProgress}
onClick={handleMemgateConfirm}
size="sm"
type="button"
variant="default"
>
<Send className="h-4 w-4" />
Send anyway
</Button>
)}
<DialogClose asChild>
<Button
disabled={isInProgress}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
</DialogClose>
</div>
) : step === "done" ? (
<Button
onClick={handleDoneClose}
size="sm"
type="button"
variant="ghost"
>
Close
</Button>
) : step === "error" ? (
<div className="flex items-center gap-2">
<Button
onClick={() => {
controller.reset();
encodeMutation.reset();
setStep("pick");
}}
size="sm"
type="button"
variant="ghost"
>
Try again
</Button>
<DialogClose asChild>
<Button size="sm" type="button" variant="ghost">
Close
</Button>
</DialogClose>
</div>
) : null}
</div>
</DialogHeader>
<Separator />
{step === "pick" ? (
<PickStep
persona={persona}
channels={filteredChannels}
isLoadingChannels={controller.isLoadingChannels}
selectedChannel={selectedChannel}
search={search}
onSearchChange={setSearch}
onSelectChannel={setSelectedChannel}
/>
) : step === "memgate" && selectedChannel !== null ? (
<MemoryGateStep
destinationLabel={selectedLabel ?? `#${selectedChannel.name}`}
memoryLevel={memoryLevel}
/>
) : step === "progress" ? (
<ProgressStep phase={controller.state.phase} />
) : step === "done" && selectedChannel !== null ? (
<DoneStep
destinationLabel={selectedLabel ?? `#${selectedChannel.name}`}
/>
) : (
<ErrorStep
error={
controller.state.error ??
(encodeMutation.error instanceof Error
? encodeMutation.error.message
: "An unexpected error occurred.")
}
/>
)}
</DialogContent>
</Dialog>
);
}
// ── 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 (
<div className="space-y-4 py-1">
<p className="text-sm text-muted-foreground">
Send{" "}
<span className="font-medium text-foreground">
{persona.displayName}
</span>{" "}
as a snapshot attachment to a channel or DM. Recipients receive the
snapshot file and can import it locally.
</p>
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input
className="w-full rounded-md border border-input bg-background py-2 pl-8 pr-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
data-testid="agent-snapshot-send-search"
placeholder="Search channels and DMs…"
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
{/* Channel list */}
<div
className="max-h-52 overflow-y-auto rounded-md border border-border"
data-testid="agent-snapshot-send-channel-list"
>
{isLoadingChannels ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
Loading…
</p>
) : channels.length === 0 ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
{search
? "No matching destinations."
: "No sendable destinations found."}
</p>
) : (
channels.map((ch) => (
<button
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-muted ${
selectedChannel?.id === ch.id ? "bg-muted font-medium" : ""
}`}
data-testid={`agent-snapshot-send-channel-${ch.id}`}
key={ch.id}
type="button"
onClick={() => onSelectChannel(ch)}
>
<span className="text-muted-foreground text-xs w-5 shrink-0">
{ch.channelType === "dm" ? "DM" : "#"}
</span>
<span className="truncate">{ch.displayLabel}</span>
{selectedChannel?.id === ch.id ? (
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
) : null}
</button>
))
)}
</div>
</div>
);
}
// ── Memory gate step ──────────────────────────────────────────────────────────
export function MemoryGateStep({
destinationLabel,
memoryLevel,
}: {
destinationLabel: string;
memoryLevel: SnapshotMemoryLevel;
}) {
const scope = memoryLevel === "core" ? "core memory" : "all memory";
return (
<div className="space-y-4 py-1">
<div
className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400"
data-testid="agent-snapshot-send-memory-gate"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-1.5">
<p>
This snapshot includes <strong>plaintext {scope}</strong>.
</p>
<ul className="list-disc space-y-1 pl-4 text-xs">
<li>
The memory will be delivered to{" "}
<strong>{destinationLabel}</strong> and visible to everyone in
that recipient surface.
</li>
<li>
Anyone who obtains the uploaded media link will also be able to
fetch the raw snapshot bytes.
</li>
</ul>
<p>
Only continue if you trust everyone who can see {destinationLabel}.
</p>
</div>
</div>
</div>
);
}
// ── Progress step ─────────────────────────────────────────────────────────────
function ProgressStep({ phase }: { phase: SendPhase }) {
const label =
phase === "preparing"
? "Preparing snapshot…"
: phase === "uploading"
? "Uploading snapshot…"
: "Sending message…";
return (
<div
className="py-6 text-center text-sm text-muted-foreground"
data-testid="agent-snapshot-send-progress"
>
{label}
</div>
);
}
// ── Done step ─────────────────────────────────────────────────────────────────
function DoneStep({ destinationLabel }: { destinationLabel: string }) {
return (
<div
className="space-y-2 py-2 text-sm"
data-testid="agent-snapshot-send-done"
>
<p>
Snapshot sent to <span className="font-medium">{destinationLabel}</span>
. Recipients can download and import the snapshot file.
</p>
</div>
);
}
// ── Error step ────────────────────────────────────────────────────────────────
function ErrorStep({ error }: { error: string }) {
return (
<div
className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
data-testid="agent-snapshot-send-error"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>{error}</p>
</div>
);
}

View File

@@ -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 ? (
<PersonaShareDialog
isCatalogVisible={
personas.personaToShare.isBuiltIn ||
personas.sharedCatalogPersonaIdSet.has(personas.personaToShare.id)
}
isPending={personas.isPending}
onCatalogVisibilityChange={(visible) => {
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 ? (
<AgentSnapshotExportDialog
agentName={personas.personaToExportSnapshot.persona.displayName}
isSavePending={personas.isPending}
open={personas.personaToExportSnapshot !== null}
persona={personas.personaToExportSnapshot.persona}
linkedAgentPubkey={personas.personaToExportSnapshot.linkedAgentPubkey}
onSaveFile={(memoryLevel, format) => {
if (personas.personaToExportSnapshot) {
@@ -441,6 +431,29 @@ export function AgentsView() {
team={teamActions.teamToAddToChannel}
/>
) : null}
{teamActions.teamToShare ? (
<TeamShareDialog
isPending={
teamActions.createTeamMutation.isPending ||
teamActions.updateTeamMutation.isPending ||
teamActions.deleteTeamMutation.isPending
}
onExport={() => {
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 ? (
<TeamSnapshotExportDialog
isSavePending={teamActions.exportTeamSnapshotMutation.isPending}

View File

@@ -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"
>
<Ellipsis className="h-4 w-4" />
<EllipsisVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem disabled={disabled} onClick={() => onShare(persona)}>
<BookUser className="h-4 w-4" />
Catalog options
</DropdownMenuItem>
{canEdit ? (
<DropdownMenuItem disabled={disabled} onClick={() => onEdit(persona)}>
<Pencil className="h-4 w-4" />
@@ -81,10 +73,10 @@ export function PersonaActionsMenu({
</DropdownMenuItem>
<DropdownMenuItem
disabled={disabled}
onClick={() => onExportSnapshot(persona, linkedAgent)}
onClick={() => onShare(persona, linkedAgent)}
>
<FileDown className="h-4 w-4" />
Export snapshot
<Share2 className="h-4 w-4" />
Share
</DropdownMenuItem>
<DropdownMenuSeparator />
{persona.sourceTeam ? (
@@ -106,7 +98,7 @@ export function PersonaActionsMenu({
}}
>
<Trash2 className="h-4 w-4" />
Remove from My Agents
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>

View File

@@ -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<SnapshotMemoryLevel, "none">;
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 (
<AlertDialog
onOpenChange={(nextOpen) => {
if (!nextOpen) onCancel();
}}
open={pendingShare !== null}
>
<AlertDialogContent data-testid={`${testIdPrefix}-memory-confirmation`}>
<AlertDialogHeader>
<AlertDialogTitle>Share memories?</AlertDialogTitle>
<AlertDialogDescription>
This {itemLabel} includes <strong>plaintext {memoryLabel}</strong>.{" "}
{isLinkShare
? "Anyone with the link can view it."
: `${recipientAudience}—and anyone with the file link—can view it.`}{" "}
Only share with people you trust.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
data-testid={`${testIdPrefix}-memory-confirm`}
onClick={() => {
if (pendingShare) onConfirm(pendingShare);
}}
type="button"
>
{isLinkShare ? "Copy link" : "Send"}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
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 (
<span
className={cn(
"inline-flex h-8 w-auto shrink-0 items-center justify-end px-2 text-sm text-muted-foreground",
staticClassName,
)}
data-testid={testId}
>
{staticLabel}
</span>
);
}
return (
<SnapshotOptionMenu
ariaLabel={ariaLabel}
className={className}
disabled={disabled}
onOpenChange={onOpenChange}
onValueChange={(nextValue) => onChange(nextValue as SnapshotMemoryLevel)}
options={options}
testId={testId}
value={value}
/>
);
}
export function SnapshotShareDialog({
displayName,
encodeSnapshot,
hasMemoryOptions,
isPending,
onCatalogVisibilityChange,
onExport,
onOpenChange,
onReset,
open,
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<CopyStatus>("idle");
const [pendingMemoryShare, setPendingMemoryShare] =
React.useState<PendingMemoryShare | null>(null);
const [linkShareLevel, setLinkShareLevel] =
React.useState<SnapshotMemoryLevel>("none");
const [recipientShareLevel, setRecipientShareLevel] =
React.useState<SnapshotMemoryLevel>("none");
const encodedSnapshotCacheRef = React.useRef(
new Map<SnapshotMemoryLevel, Promise<EncodedSnapshot>>(),
);
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<BlobDescriptor> {
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 (
<Dialog onOpenChange={handleDialogOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
className="max-w-xl gap-3 bg-transparent p-0 shadow-none"
data-testid={`${testIdPrefix}-dialog`}
showCloseButton={false}
>
<div
className="relative rounded-2xl bg-background p-6 pb-4 shadow-2xl"
data-testid={`${testIdPrefix}-main-card`}
>
<DialogHeader className="space-y-0">
<DialogTitle className="min-w-0 truncate pr-10">
Share {displayName}
</DialogTitle>
</DialogHeader>
<DialogClose
className="absolute right-4 top-4 flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-100"
disabled={isActionPending}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogClose>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<div className="flex items-start gap-2">
<motion.div
className="min-w-0 flex-1"
layout
transition={recipientActionTransition}
>
<PersonaShareRecipients
disabled={
isInterfacePending ||
!snapshotSendController.isDmSafetyReady
}
excludedPubkeys={excludedRecipientPubkeys}
onSelectionChange={setSelectedRecipients}
open={open}
renderEndControl={(handleAccessOpenChange) => (
<ShareLevelControl
ariaLabel="What to include"
className="-mr-2 h-7"
disabled={isInterfacePending}
hasMemoryOptions={hasMemoryOptions}
onChange={setRecipientShareLevel}
onOpenChange={handleAccessOpenChange}
options={shareLevels}
staticLabel={`${itemLabelTitle} only`}
staticClassName="-mr-2 h-7 w-auto"
testId={`${testIdPrefix}-recipient-access`}
value={recipientShareLevel}
/>
)}
selectedUsers={selectedRecipients}
testIdPrefix={testIdPrefix}
/>
</motion.div>
<AnimatePresence initial={false} mode="popLayout">
{hasSelectedRecipients ? (
<motion.div
animate={{ opacity: 1 }}
className="shrink-0"
data-testid={`${testIdPrefix}-send-motion`}
exit={{ opacity: 0 }}
initial={shouldReduceMotion ? false : { opacity: 0 }}
layout
transition={recipientActionTransition}
>
<Button
className="h-10"
data-testid={`${testIdPrefix}-send`}
disabled={
isActionPending ||
!snapshotSendController.isDmSafetyReady
}
onClick={() =>
requestMemoryShare("send", recipientShareLevel)
}
type="button"
>
{isSending ? "Sending…" : "Send"}
</Button>
</motion.div>
) : null}
</AnimatePresence>
</div>
<p
className="text-xs text-secondary-foreground/75"
data-testid={`${testIdPrefix}-send-description`}
>
They’ll receive a copy they can add and use. Changes you make
later won’t sync.
</p>
</div>
<AnimatePresence initial={false}>
{showMemoryWarning ? (
<motion.div
animate={{ height: "auto", opacity: 1 }}
className="overflow-hidden"
data-testid={`${testIdPrefix}-memory-warning-motion`}
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
key={`${testIdPrefix}-memory-warning`}
transition={warningTransition}
>
<div
className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400"
data-testid={`${testIdPrefix}-memory-warning`}
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Memory is stored as <strong>plaintext</strong> in the
snapshot. Only share it with people you trust.
</p>
</div>
</motion.div>
) : null}
</AnimatePresence>
<section
className="pt-6"
data-testid={`${testIdPrefix}-copy-link-footer`}
>
<div
className="flex items-center gap-3"
data-testid={`${testIdPrefix}-link-row`}
>
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"
data-testid={`${testIdPrefix}-link-icon`}
>
<Link2 className="h-4 w-4" />
</span>
<div
className="min-w-0 flex-1"
data-testid={`${testIdPrefix}-link-copy`}
>
<h3 className="text-sm font-medium">Share with a link</h3>
<p className="text-xs text-secondary-foreground/75">
Anyone with the link can add and use a copy.
</p>
</div>
<ShareLevelControl
ariaLabel="What to include in the link"
disabled={isInterfacePending}
hasMemoryOptions={hasMemoryOptions}
onChange={setLinkShareLevel}
options={shareLevels}
staticLabel={`${itemLabelTitle} only`}
testId={`${testIdPrefix}-link-access`}
value={linkShareLevel}
/>
</div>
<Separator
className="my-4 bg-input/40"
data-testid={`${testIdPrefix}-link-divider`}
/>
<div className="flex justify-end">
<Button
asChild
className="shrink-0 border-border shadow-none disabled:opacity-100"
data-copy-status={copyStatus}
data-testid={`${testIdPrefix}-copy-link`}
disabled={isActionPending}
onClick={() => requestMemoryShare("copy", linkShareLevel)}
size="sm"
type="button"
variant="outline"
>
<motion.button
layout={!shouldReduceMotion}
layoutDependency={copyStatus}
style={{ transformOrigin: "100% 50%" }}
transition={copyButtonLayoutTransition}
>
<span aria-live="polite" className="sr-only">
{copyStatusLabel}
</span>
<span
aria-hidden="true"
className="relative grid place-items-center"
data-testid={`${testIdPrefix}-copy-link-stage`}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
animate={{
filter: "blur(0px)",
opacity: 1,
transform: "scale(1)",
}}
className="col-start-1 row-start-1 flex items-center justify-center gap-1.5 [transform-origin:50%_50%] will-change-[transform,opacity,filter]"
data-testid={`${testIdPrefix}-copy-link-state`}
exit={
shouldReduceMotion
? { opacity: 0 }
: {
filter: "blur(2px)",
opacity: 0,
transform: "scale(0.97)",
}
}
initial={
shouldReduceMotion
? false
: {
filter: "blur(2px)",
opacity: 0,
transform: "scale(0.97)",
}
}
key={copyStatus}
transition={copyFeedbackTransition}
>
{copyStatus === "copying" ? (
<Spinner
aria-hidden="true"
className="h-4 w-4 border-2"
/>
) : copyStatus === "copied" ? (
<Check aria-hidden="true" className="h-4 w-4" />
) : (
<Link2 aria-hidden="true" className="h-4 w-4" />
)}
<span>{copyStatusLabel}</span>
</motion.span>
</AnimatePresence>
</span>
</motion.button>
</Button>
</div>
</section>
</div>
</div>
<button
className="relative flex min-h-14 w-full items-center gap-3 rounded-2xl bg-background px-5 py-4 text-left text-sm font-medium shadow-2xl outline-hidden transition-colors hover:bg-muted focus-visible:bg-muted disabled:cursor-default disabled:opacity-100"
data-testid={`${testIdPrefix}-export`}
disabled={isActionPending}
onClick={onExport}
type="button"
>
<Download className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">Export {itemLabel}</span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</DialogContent>
<MemoryShareConfirmation
itemLabel={itemLabel}
onCancel={() => setPendingMemoryShare(null)}
onConfirm={confirmMemoryShare}
pendingShare={pendingMemoryShare}
testIdPrefix={testIdPrefix}
/>
</Dialog>
);
}
export function PersonaShareDialog({
isPending,
linkedAgentPubkey,
onExport,
onOpenChange,
open,
persona,
}: PersonaShareDialogProps) {
const switchId = `persona-share-catalog-${persona.id}`;
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 (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
className="max-w-md"
data-testid="persona-share-dialog"
showCloseButton={false}
>
<DialogHeader className="space-y-0">
<div className="flex items-center justify-between gap-4">
<DialogTitle>Catalog options</DialogTitle>
<div className="flex items-center gap-2">
<Button
data-testid="persona-share-export"
disabled={isPending}
onClick={onExport}
size="sm"
type="button"
variant="outline"
>
<Download className="h-4 w-4" />
Export
</Button>
<DialogClose className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</div>
</DialogHeader>
<div>
<div className="flex min-w-0 items-center gap-3 py-4">
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-10 w-10 text-xs"
label={persona.displayName}
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-snug">
{persona.displayName}
</p>
{persona.isBuiltIn ? null : <PersonaAddedBy className="mt-0.5" />}
</div>
</div>
<Separator />
<div className="flex items-center justify-between gap-4 py-4">
<div className="flex min-w-0 items-center gap-2">
<BookUser className="h-4 w-4 shrink-0 text-muted-foreground" />
<label className="text-sm font-medium" htmlFor={switchId}>
Show in my catalog
</label>
</div>
<Switch
checked={isCatalogVisible}
data-testid="persona-share-show-in-catalog"
disabled={persona.isBuiltIn || isPending}
id={switchId}
onCheckedChange={onCatalogVisibilityChange}
/>
</div>
</div>
</DialogContent>
</Dialog>
<SnapshotShareDialog
displayName={persona.displayName}
encodeSnapshot={encodeSnapshot}
hasMemoryOptions={linkedAgentPubkey !== null}
isPending={isPending}
onExport={onExport}
onOpenChange={onOpenChange}
onReset={encodeSnapshotMutation.reset}
open={open}
snapshotKind="agent"
testIdPrefix="persona-share"
/>
);
}

View File

@@ -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<HTMLDivElement>(null);
const searchInputRef = React.useRef<HTMLInputElement>(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 (
<div className="min-w-0 flex-1">
<Popover
modal={false}
onOpenChange={setIsPickerOpen}
open={isPickerOpen && !disabled}
>
<PopoverAnchor asChild>
{/* 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 */}
<div
className="grid min-h-10 min-w-0 cursor-text grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 rounded-md border border-input bg-background px-2 py-1.5 focus-within:ring-1 focus-within:ring-ring"
data-testid={`${testIdPrefix}-recipient-field`}
onClick={() => {
if (disabled) return;
setIsPickerOpen(true);
searchInputRef.current?.focus({ preventScroll: true });
}}
ref={recipientFieldRef}
>
<div
className="flex min-w-0 flex-wrap items-center gap-1.5"
data-testid={`${testIdPrefix}-recipient-input-region`}
>
{selectedUsers.length === 0 ? (
<Search className="h-4 w-4 shrink-0 text-muted-foreground/55" />
) : null}
{selectedUsers.map((user) => (
<SelectedRecipientChip
disabled={disabled}
inspectable={false}
key={user.pubkey}
label={formatShareRecipientName(user)}
onRemove={() => removeUser(user.pubkey)}
poofOnRemove={false}
testIds={{
chip: `${testIdPrefix}-recipient-chip-${user.pubkey}`,
}}
user={user}
/>
))}
<input
aria-autocomplete="list"
aria-controls={`${testIdPrefix}-recipient-results`}
aria-expanded={isPickerOpen && !disabled}
aria-label="Share with"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className="h-7 min-w-16 flex-1 border-0 bg-transparent p-0 text-sm outline-hidden placeholder:text-muted-foreground/55"
data-testid={`${testIdPrefix}-recipient-search`}
disabled={disabled || selectedUsers.length >= 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}
/>
</div>
{selectedUsers.length > 0 && renderEndControl
? renderEndControl((controlOpen) => {
if (controlOpen) setIsPickerOpen(false);
})
: null}
</div>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-(--radix-popover-trigger-width) overflow-hidden p-0"
data-testid={`${testIdPrefix}-recipient-popover`}
onCloseAutoFocus={(event) => 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}
>
<div
className="max-h-64 overflow-y-auto overscroll-contain py-1"
data-testid={`${testIdPrefix}-recipient-results`}
id={`${testIdPrefix}-recipient-results`}
onScroll={handleDirectoryScroll}
onTouchMoveCapture={(event) => event.stopPropagation()}
onWheelCapture={(event) => event.stopPropagation()}
role="listbox"
>
{isSearchSettling ? (
<div
aria-label="Loading people"
className="space-y-3 px-3 py-3"
role="status"
>
{["w-36", "w-28", "w-40"].map((width) => (
<div className="flex items-center gap-3" key={width}>
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
<Skeleton className={`h-4 ${width}`} />
</div>
))}
</div>
) : visibleSearchResults.length > 0 ? (
visibleSearchResults.map((user) => (
<button
aria-label={`Add ${formatShareRecipientName(user)}`}
className="flex min-h-11 w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-hidden"
data-testid={`${testIdPrefix}-recipient-option-${user.pubkey}`}
key={user.pubkey}
onClick={() => selectUser(user)}
role="option"
type="button"
>
<ProfileAvatar
avatarUrl={user.avatarUrl}
className="h-8 w-8 text-xs shadow-none"
iconClassName="h-4 w-4"
label={formatShareRecipientName(user)}
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{formatShareRecipientName(user)}
</span>
</button>
))
) : (
<p className="px-3 py-3 text-sm text-muted-foreground">
No people found.
</p>
)}
</div>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -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 (
<DropdownMenu modal={false} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
aria-label={ariaLabel}
className={cn(
"inline-flex h-8 w-auto shrink-0 items-center justify-end gap-1.5 rounded-md bg-transparent px-2 text-right text-sm text-muted-foreground outline-hidden transition-colors hover:bg-muted/50 hover:text-foreground focus-visible:bg-muted/50 focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
data-testid={testId}
disabled={disabled}
onClick={(event) => event.stopPropagation()}
type="button"
>
<span className="min-w-0 truncate">{selectedLabel}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
sideOffset={4}
style={{
minWidth: "max(var(--radix-dropdown-menu-trigger-width), 13rem)",
}}
>
<DropdownMenuRadioGroup onValueChange={onValueChange} value={value}>
{options.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -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 (
<SnapshotShareDialog
displayName={team.name}
encodeSnapshot={encodeSnapshot}
hasMemoryOptions
isPending={isPending}
onExport={onExport}
onOpenChange={onOpenChange}
open={open}
snapshotKind="team"
testIdPrefix="team-share"
/>
);
}

View File

@@ -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<SnapshotMemoryLevel>("none");
const [format, setFormat] = React.useState<SnapshotFormat>("json");
const [sendOpen, setSendOpen] = React.useState(false);
const [format, setFormat] = React.useState<SnapshotFormat>("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 (
<>
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
className="max-w-md"
data-testid="team-snapshot-export-dialog"
showCloseButton={false}
>
<DialogHeader className="space-y-0">
<div className="flex items-center justify-between gap-4">
<DialogTitle>Export team snapshot</DialogTitle>
<div className="flex items-center gap-2">
{/* Primary: Send in Buzz */}
<Button
data-testid="team-snapshot-send-in-buzz"
disabled={isPending}
onClick={() => setSendOpen(true)}
size="sm"
type="button"
variant="default"
>
<Send className="h-4 w-4" />
Send in Buzz
</Button>
{/* Secondary: Save file */}
<Button
data-testid="team-snapshot-export-confirm"
disabled={isPending}
onClick={() => onSaveFile(memoryLevel, format)}
size="sm"
type="button"
variant="outline"
>
<Download className="h-4 w-4" />
Save file
</Button>
<DialogClose asChild>
<Button
disabled={isPending}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
</DialogClose>
</div>
</div>
</DialogHeader>
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
className="max-w-md"
data-testid="team-snapshot-export-dialog"
showCloseButton={false}
>
<DialogHeader className="space-y-0">
<DialogTitle className="truncate">Export {team.name}</DialogTitle>
</DialogHeader>
<Separator />
<div className="space-y-4 py-1">
{/* Team identity */}
<p className="text-sm text-muted-foreground">
Exporting{" "}
<span className="font-medium text-foreground">{team.name}</span>{" "}
as a portable snapshot. The recipient imports it as a <em>new</em>{" "}
team with fresh keys — identity never travels.
</p>
{/* Memory level picker */}
<div className="space-y-2">
<p className="text-sm font-medium">Memory to include</p>
<div className="space-y-1">
{MEMORY_LEVELS.map(({ value, label, description }) => (
<label
className="flex cursor-pointer items-start gap-3 rounded-md px-3 py-2 hover:bg-muted"
key={value}
>
<input
checked={memoryLevel === value}
className="mt-0.5 shrink-0"
name="memory-level"
onChange={() => setMemoryLevel(value)}
type="radio"
value={value}
/>
<div>
<p className="text-sm font-medium leading-none">
{label}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{description}
</p>
</div>
</label>
))}
</div>
<div className="space-y-4 pt-2">
<div className="space-y-1">
<div className="flex min-h-8 items-center justify-between gap-4">
<span className="flex min-w-0 items-center gap-2 text-sm font-medium">
<Brain className="h-4 w-4 shrink-0 text-muted-foreground" />
Memories
</span>
<SnapshotOptionMenu
ariaLabel="Memories"
className="font-medium text-foreground"
disabled={isSavePending}
onValueChange={(value) =>
setMemoryLevel(value as SnapshotMemoryLevel)
}
options={MEMORY_LEVELS}
testId="team-snapshot-memory-trigger"
value={memoryLevel}
/>
</div>
{/* Plaintext memory warning */}
{showMemoryWarning ? (
<div
className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400"
data-testid="team-snapshot-memory-warning"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Memory is stored as <strong>plaintext</strong> in the
snapshot. Only share it with people you trust.
</p>
</div>
) : null}
{/* Format picker */}
<div className="space-y-2">
<p className="text-sm font-medium">File format</p>
<div className="flex gap-3">
<label className="flex cursor-pointer items-center gap-2">
<input
checked={format === "json"}
name="snapshot-format"
onChange={() => setFormat("json")}
type="radio"
value="json"
/>
<span className="text-sm">.team.json</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
checked={format === "png"}
name="snapshot-format"
onChange={() => setFormat("png")}
type="radio"
value="png"
/>
<span className="text-sm">.team.png</span>
</label>
</div>
<p className="text-xs text-muted-foreground">
Applies to saved files; snapshots shared in Buzz always use
.team.png. PNG exports include memory when selected.
</p>
<div className="flex min-h-8 items-center justify-between gap-4">
<span className="flex min-w-0 items-center gap-2 text-sm font-medium">
<FileType2 className="h-4 w-4 shrink-0 text-muted-foreground" />
File format
</span>
<SnapshotOptionMenu
ariaLabel="File format"
className="font-medium text-foreground"
disabled={isSavePending}
onValueChange={(value) => setFormat(value as SnapshotFormat)}
options={FORMAT_OPTIONS}
testId="team-snapshot-format-trigger"
value={format}
/>
</div>
</div>
</DialogContent>
</Dialog>
{/* Send-in-Buzz destination picker — opened as a secondary dialog */}
{sendOpen ? (
<TeamSnapshotSendDialog
memoryLevel={memoryLevel}
open={sendOpen}
team={team}
onOpenChange={(open) => {
setSendOpen(open);
}}
onSent={() => {
setSendOpen(false);
onOpenChange(false);
}}
/>
) : null}
</>
<AnimatePresence initial={false}>
{showMemoryWarning ? (
<motion.div
animate={{ height: "auto", opacity: 1 }}
className="overflow-hidden"
data-testid="team-snapshot-memory-warning-motion"
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
key="team-snapshot-memory-warning"
transition={modalResizeTransition}
>
<div
className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400"
data-testid="team-snapshot-memory-warning"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Memory is stored as <strong>plaintext</strong> in the
snapshot. Only share it with people you trust.
</p>
</div>
</motion.div>
) : null}
</AnimatePresence>
<div
className="flex items-center justify-end gap-2 pt-2"
data-testid="team-snapshot-export-footer"
>
<DialogClose asChild>
<Button
disabled={isSavePending}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
</DialogClose>
<Button
data-testid="team-snapshot-export-confirm"
disabled={isSavePending}
onClick={() => onSaveFile(memoryLevel, format)}
size="sm"
type="button"
>
<Download className="h-4 w-4" />
Export
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<typeof buildTeamSnapshotSendArgs>) =>
encodeTeamSnapshotForSend(id, memoryLevel, format),
});
const timeoutState = useTimeoutState();
const [step, setStep] = React.useState<DialogStep>("pick");
const [selectedChannel, setSelectedChannel] =
React.useState<ResolvedChannel | null>(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 (
<Dialog onOpenChange={handleClose} open={open}>
<DialogContent
aria-describedby={undefined}
className="max-w-md"
data-testid="team-snapshot-send-dialog"
showCloseButton={false}
>
<DialogHeader className="space-y-0">
<div className="flex items-center justify-between gap-4">
<DialogTitle>
{step === "done"
? "Snapshot sent"
: step === "memgate"
? "Confirm memory share"
: "Send team snapshot in Buzz"}
</DialogTitle>
{step !== "progress" && step !== "done" && step !== "error" ? (
<div className="flex items-center gap-2">
{step === "pick" ? (
<Button
data-testid="team-snapshot-send-confirm"
disabled={selectedChannel === null || isInProgress}
onClick={handlePickConfirm}
size="sm"
type="button"
variant="default"
>
<Send className="h-4 w-4" />
{hasMemory ? "Next" : "Send"}
</Button>
) : (
// memgate step
<Button
data-testid="team-snapshot-send-memgate-confirm"
disabled={isInProgress}
onClick={handleMemgateConfirm}
size="sm"
type="button"
variant="default"
>
<Send className="h-4 w-4" />
Send anyway
</Button>
)}
<DialogClose asChild>
<Button
disabled={isInProgress}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
</DialogClose>
</div>
) : step === "done" ? (
<Button
onClick={handleDoneClose}
size="sm"
type="button"
variant="ghost"
>
Close
</Button>
) : step === "error" ? (
<div className="flex items-center gap-2">
<Button
onClick={() => {
controller.reset();
encodeMutation.reset();
setStep("pick");
}}
size="sm"
type="button"
variant="ghost"
>
Try again
</Button>
<DialogClose asChild>
<Button size="sm" type="button" variant="ghost">
Close
</Button>
</DialogClose>
</div>
) : null}
</div>
</DialogHeader>
<Separator />
{step === "pick" ? (
<PickStep
team={team}
channels={filteredChannels}
isLoadingChannels={controller.isLoadingChannels}
selectedChannel={selectedChannel}
search={search}
onSearchChange={setSearch}
onSelectChannel={setSelectedChannel}
/>
) : step === "memgate" && selectedChannel !== null ? (
<MemoryGateStep
destinationLabel={selectedLabel ?? `#${selectedChannel.name}`}
memoryLevel={memoryLevel}
/>
) : step === "progress" ? (
<ProgressStep phase={controller.state.phase} />
) : step === "done" && selectedChannel !== null ? (
<DoneStep
destinationLabel={selectedLabel ?? `#${selectedChannel.name}`}
/>
) : (
<ErrorStep
error={
controller.state.error ??
(encodeMutation.error instanceof Error
? encodeMutation.error.message
: "An unexpected error occurred.")
}
/>
)}
</DialogContent>
</Dialog>
);
}
// ── 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 (
<div className="space-y-4 py-1">
<p className="text-sm text-muted-foreground">
Send <span className="font-medium text-foreground">{team.name}</span> as
a team snapshot attachment to a channel or DM. Recipients receive the
snapshot file and can import it locally.
</p>
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input
className="w-full rounded-md border border-input bg-background py-2 pl-8 pr-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
data-testid="team-snapshot-send-search"
placeholder="Search channels and DMs…"
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
{/* Channel list */}
<div
className="max-h-52 overflow-y-auto rounded-md border border-border"
data-testid="team-snapshot-send-channel-list"
>
{isLoadingChannels ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
Loading…
</p>
) : channels.length === 0 ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
{search
? "No matching destinations."
: "No sendable destinations found."}
</p>
) : (
channels.map((ch) => (
<button
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-muted ${
selectedChannel?.id === ch.id ? "bg-muted font-medium" : ""
}`}
data-testid={`team-snapshot-send-channel-${ch.id}`}
key={ch.id}
type="button"
onClick={() => onSelectChannel(ch)}
>
<span className="text-muted-foreground text-xs w-5 shrink-0">
{ch.channelType === "dm" ? "DM" : "#"}
</span>
<span className="truncate">{ch.displayLabel}</span>
{selectedChannel?.id === ch.id ? (
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
) : null}
</button>
))
)}
</div>
</div>
);
}
// ── Progress step ─────────────────────────────────────────────────────────────
function ProgressStep({ phase }: { phase: SendPhase }) {
const label =
phase === "preparing"
? "Preparing snapshot…"
: phase === "uploading"
? "Uploading snapshot…"
: "Sending message…";
return (
<div
className="py-6 text-center text-sm text-muted-foreground"
data-testid="team-snapshot-send-progress"
>
{label}
</div>
);
}
// ── Done step ─────────────────────────────────────────────────────────────────
function DoneStep({ destinationLabel }: { destinationLabel: string }) {
return (
<div
className="space-y-2 py-2 text-sm"
data-testid="team-snapshot-send-done"
>
<p>
Team snapshot sent to{" "}
<span className="font-medium">{destinationLabel}</span>. Recipients can
download and import the snapshot file.
</p>
</div>
);
}
// ── Error step ────────────────────────────────────────────────────────────────
function ErrorStep({ error }: { error: string }) {
return (
<div
className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
data-testid="team-snapshot-send-error"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>{error}</p>
</div>
);
}

View File

@@ -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({
<DropdownMenuTrigger asChild>
<button
aria-label={`${team.name} team actions`}
className="flex h-7 w-7 items-center justify-center rounded-md bg-transparent text-muted-foreground/80 transition-colors hover:bg-background/85 hover:text-foreground data-[state=open]:bg-background/90 data-[state=open]:text-foreground"
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"
>
<Ellipsis className="h-4 w-4" />
<EllipsisVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -118,13 +118,6 @@ export function TeamsSection({
Deploy to channel
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isPending || hasMissingPersonas}
onClick={() => onExport(team)}
>
<Download className="h-4 w-4" />
Export snapshot
</DropdownMenuItem>
<DropdownMenuItem
disabled={isPending}
onClick={() => onEdit(team)}
@@ -139,6 +132,13 @@ export function TeamsSection({
<CopyPlus className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
disabled={isPending || hasMissingPersonas}
onClick={() => onShare(team)}
>
<Share2 className="h-4 w-4" />
Share
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
@@ -168,7 +168,7 @@ export function TeamsSection({
{missingPersonaCount === 1 ? "" : "s"} in this team{" "}
{missingPersonaCount === 1 ? "is" : "are"} no longer in your
My Agents. Edit the team to repair it before deploying or
exporting.
sharing.
</p>
) : null}
</TeamIdentityCard>

View File

@@ -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}
/>
}

View File

@@ -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}`);

View File

@@ -37,8 +37,10 @@ test("resolveSnapshotAvatarPng: emoji SVG is rasterized onto a canvas", async ()
},
};
const emojiSvg =
'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" rx="256" fill="#ffcc00"/><text>✨</text></svg>';
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,
/<rect width="512" height="512" fill="#ffcc00"\/>/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]]);

View File

@@ -54,7 +54,7 @@ async function rasterizeSvg(
): Promise<string | undefined> {
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(
/(<rect\b[^>]*\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;

View File

@@ -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" };
}

View File

@@ -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<PersonaDialogState | null>(null);
const [personaToDelete, setPersonaToDelete] =
React.useState<AgentPersona | null>(null);
const [personaToShare, setPersonaToShare] =
React.useState<AgentPersona | null>(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<string | null>(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,

View File

@@ -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<typeof createSendGuard>,
pipelineDeps: Parameters<typeof runSendPipeline>[0],
resolveChannelId: () => Promise<string>,
buildPipelineDeps: (
channelId: string,
) => Parameters<typeof runSendPipeline>[0],
setStateFn: (state: SnapshotSendState) => void,
): Promise<boolean> {
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<boolean>;
/** Set state to error with a message (for pre-send gate failures). */
setErrorState: (message: string) => void;
resolveChannelId: () => Promise<string>,
attachmentLabel?: string,
) => Promise<boolean | null>;
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<SnapshotSendState>({
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<ResolvedChannel[]>(() => {
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<boolean> {
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<string>,
attachmentLabel?: string,
): Promise<boolean | null> {
// 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,
};
}

View File

@@ -66,6 +66,7 @@ export function useTeamActions(
const [teamToExport, setTeamToExport] = React.useState<AgentTeam | null>(
null,
);
const [teamToShare, setTeamToShare] = React.useState<AgentTeam | null>(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,

View File

@@ -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<AgentManagementRequest, { action: "update" }>,
@@ -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(

View File

@@ -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 <Agent> "One"',
});
assert.match(html, /Research &lt;Agent&gt; &quot;One&quot;/);
assert.match(html, /x=1&amp;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 = `<a data-buzz-agent-snapshot="${encodedPayload}">Snapshot</a>`;
assert.doesNotThrow(() => parseAgentSnapshotClipboardHtml(html));
assert.equal(parseAgentSnapshotClipboardHtml(html), null);
}
});
test("ordinary clipboard HTML is ignored", () => {
assert.equal(
parseAgentSnapshotClipboardHtml('<a href="https://example.com">link</a>'),
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);
});

View File

@@ -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("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
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 `<a ${CLIPBOARD_ATTRIBUTE}="${encodedPayload}" href="${escapeHtml(attachment.url)}">${escapeHtml(displayName)}</a>`;
}
export function buildAgentSnapshotClipboardHtml(
input: Omit<Parameters<typeof buildSnapshotClipboardHtml>[0], "snapshotKind">,
): string {
return buildSnapshotClipboardHtml({ ...input, snapshotKind: "agent" });
}
export function buildTeamSnapshotClipboardHtml(
input: Omit<Parameters<typeof buildSnapshotClipboardHtml>[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<SnapshotClipboardPayload>;
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<ClipboardEvent, "clipboardData" | "preventDefault">,
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;

View File

@@ -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)}`;

View File

@@ -534,6 +534,7 @@ export function collectMessageAuthorPubkeys(events: RelayEvent[]) {
pubkeys.add(pk);
}
} else {
pubkeys.add(event.pubkey.toLowerCase());
pubkeys.add(
resolveEventAuthorPubkey({
pubkey: event.pubkey,

View File

@@ -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",

View File

@@ -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>,
): ImetaMedia[] {
if (imetaMedia.length === 0) return [];
const urls = new Set(imetaMedia.map((media) => media.url));
const labelsByUrl = new Map<string, string>();
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),
});
}

View File

@@ -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);
});

View File

@@ -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<TimelineMessage, "signerPubkey">,
profiles?: UserProfileLookup,
): string | undefined {
if (!message.signerPubkey) return undefined;
return resolveUserLabel({
profiles,
pubkey: message.signerPubkey,
});
}

View File

@@ -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;
}

View File

@@ -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<string>;
};
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 (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="min-w-0 max-w-full"
exit={{ opacity: 0, scale: 0.8 }}
initial={false}
layout
transition={{ type: "spring", stiffness: 500, damping: 30 }}
>
<Attachment
className="w-fit max-w-full shadow-none"
data-testid={`composer-${snapshotKind}-snapshot-card`}
size="sm"
>
<AttachmentMedia
className={
showThumb
? "relative h-9 w-9"
: "bg-primary/10 text-primary ring-1 ring-primary/20 dark:bg-primary/15"
}
variant={showThumb ? "image" : "icon"}
>
{showThumb ? (
<>
<img
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full scale-150 object-cover"
src={rewriteRelayUrl(attachment.url)}
/>
<img
alt=""
className="relative h-full w-full object-cover"
src={rewriteRelayUrl(attachment.url)}
onError={() => setThumbError(true)}
/>
</>
) : (
<SnapshotIcon />
)}
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle className="overflow-visible whitespace-normal text-clip">
{displayName}
</AttachmentTitle>
<AttachmentDescription className="text-secondary-foreground/75">
{formatAttachmentSize(attachment.size)}
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions className="ml-4">
<AttachmentAction
aria-label={`Remove ${displayName}`}
className="border-0 bg-transparent text-muted-foreground/70 shadow-none hover:text-foreground hover:shadow-none focus-visible:bg-muted focus-visible:ring-0"
data-testid={`composer-${snapshotKind}-snapshot-remove`}
onClick={() => onRemove(attachment.url)}
title="Remove"
type="button"
>
<X />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</motion.div>
);
}
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 (
<ComposerSnapshotCard
attachment={attachment}
key={attachment.url}
onRemove={onRemove}
snapshotKind={snapshotKind}
/>
);
}
// Generic file: compact chip with a file icon + filename, plus the
// same remove button. No lightbox (nothing to preview).
if (isFile) {

View File

@@ -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(

View File

@@ -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}
/>
);

View File

@@ -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:
</span>
{selectedUsers.map((user) => (
<div
className="inline-flex h-7 max-w-56 items-center gap-1.5 rounded-full bg-muted px-1.5 pr-2.5 text-sm transition-colors hover:bg-muted/80"
<SelectedRecipientChip
disabled={isPending}
inspectionOpen={inspectedRecipientPubkey === user.pubkey}
key={user.pubkey}
>
<button
aria-label={`Remove ${formatRecipientName(user)}`}
className={cn(
"group/remove-recipient relative h-5 w-5 shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
POOF_TRIGGER_CLASS,
POOF_ORIGIN_CLASS,
)}
data-testid={`new-dm-selected-${user.pubkey}`}
disabled={isPending}
onClick={(event) => {
event.stopPropagation();
handleRemoveUser(user.pubkey);
}}
onPointerDown={(event) => {
if (event.button !== 0) {
return;
}
event.stopPropagation();
handleRemoveUser(user.pubkey);
}}
type="button"
>
<ProfileAvatar
avatarUrl={user.avatarUrl}
className="h-5 w-5 text-3xs shadow-none transition-opacity group-hover/remove-recipient:opacity-0 group-focus-visible/remove-recipient:opacity-0"
iconClassName="h-2.5 w-2.5"
label={formatRecipientName(user)}
/>
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-foreground text-background opacity-0 transition-opacity group-hover/remove-recipient:opacity-100 group-focus-visible/remove-recipient:opacity-100">
<X aria-hidden="true" className="h-3 w-3" />
</span>
</button>
<Popover
onOpenChange={(open) => {
setInspectedRecipientPubkey(open ? user.pubkey : null);
}}
open={inspectedRecipientPubkey === user.pubkey}
>
<PopoverAnchor asChild>
<button
aria-expanded={
inspectedRecipientPubkey === user.pubkey
}
aria-haspopup="dialog"
aria-label={`Verify ${formatRecipientName(user)} public key`}
className="min-w-0 cursor-pointer truncate rounded font-medium hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
data-testid={`new-dm-recipient-name-${user.pubkey}`}
onClick={(event) => {
event.stopPropagation();
setInspectedRecipientPubkey((current) =>
current === user.pubkey ? null : user.pubkey,
);
}}
type="button"
>
{formatRecipientName(user)}
</button>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-96 max-w-[90vw] space-y-2"
data-new-dm-key-popover=""
data-testid={`new-dm-selected-key-popover-${user.pubkey}`}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<p className="text-sm font-medium">
Verify {formatRecipientName(user)}
</p>
<PubKey
pubkey={user.pubkey}
testId={`new-dm-selected-pubkey-${user.pubkey}`}
variant="full"
/>
<p className="break-all font-mono text-xs text-muted-foreground">
{user.pubkey}
</p>
</PopoverContent>
</Popover>
{user.isAgent ? (
<Bot
aria-label="agent"
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
) : null}
</div>
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}
/>
))}
<input
aria-activedescendant={
@@ -562,7 +485,7 @@ export function NewMessageScreen() {
if (
target instanceof Element &&
(toFieldRef.current?.contains(target) ||
Boolean(target.closest("[data-new-dm-key-popover]")))
Boolean(target.closest("[data-recipient-key-popover]")))
) {
event.preventDefault();
}

View File

@@ -0,0 +1,130 @@
import { Bot, X } from "lucide-react";
import type { UserSearchResult } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import {
POOF_ORIGIN_CLASS,
POOF_POINTER_ORIGIN_CLASS,
POOF_TRIGGER_CLASS,
} from "@/shared/ui/PoofBurstProvider";
import { PubKey } from "@/shared/ui/PubKey";
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
import { ProfileAvatar } from "./ProfileAvatar";
type SelectedRecipientChipTestIds = {
chip?: string;
keyPopover?: string;
name?: string;
pubkey?: string;
};
/**
* The selected-recipient pill shared by recipient pickers. Its avatar becomes
* the remove affordance on hover/focus. Surfaces that need identity
* verification can also make the name open the recipient's full key.
*/
export function SelectedRecipientChip({
disabled,
inspectable = true,
inspectionOpen = false,
label,
onInspectionOpenChange,
onRemove,
poofOnRemove = true,
testIds,
user,
}: {
disabled: boolean;
inspectable?: boolean;
inspectionOpen?: boolean;
label: string;
onInspectionOpenChange?: (open: boolean) => void;
onRemove: () => void;
poofOnRemove?: boolean;
testIds?: SelectedRecipientChipTestIds;
user: UserSearchResult;
}) {
return (
<div className="inline-flex h-7 max-w-56 items-center gap-1.5 rounded-full bg-muted px-1.5 pr-2.5 text-sm transition-colors hover:bg-muted/80">
<button
aria-label={`Remove ${label}`}
className={cn(
"group/remove-recipient relative h-5 w-5 shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
poofOnRemove && POOF_TRIGGER_CLASS,
poofOnRemove && POOF_ORIGIN_CLASS,
poofOnRemove && POOF_POINTER_ORIGIN_CLASS,
)}
data-testid={testIds?.chip}
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onRemove();
}}
onPointerDown={(event) => {
if (event.button !== 0) {
return;
}
event.stopPropagation();
onRemove();
}}
type="button"
>
<ProfileAvatar
avatarUrl={user.avatarUrl}
className="h-5 w-5 text-3xs shadow-none transition-opacity group-hover/remove-recipient:opacity-0 group-focus-visible/remove-recipient:opacity-0"
iconClassName="h-2.5 w-2.5"
label={label}
/>
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-foreground text-background opacity-0 transition-opacity group-hover/remove-recipient:opacity-100 group-focus-visible/remove-recipient:opacity-100">
<X aria-hidden="true" className="h-3 w-3" />
</span>
</button>
{inspectable ? (
<Popover onOpenChange={onInspectionOpenChange} open={inspectionOpen}>
<PopoverAnchor asChild>
<button
aria-expanded={inspectionOpen}
aria-haspopup="dialog"
aria-label={`Verify ${label} public key`}
className="min-w-0 cursor-pointer truncate rounded font-medium hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
data-testid={testIds?.name}
onClick={(event) => {
event.stopPropagation();
onInspectionOpenChange?.(!inspectionOpen);
}}
type="button"
>
{label}
</button>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-96 max-w-[90vw] space-y-2"
data-recipient-key-popover=""
data-testid={testIds?.keyPopover}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<p className="text-sm font-medium">Verify {label}</p>
<PubKey
pubkey={user.pubkey}
testId={testIds?.pubkey}
variant="full"
/>
<p className="break-all font-mono text-xs text-muted-foreground">
{user.pubkey}
</p>
</PopoverContent>
</Popover>
) : (
<span className="min-w-0 truncate font-medium">{label}</span>
)}
{user.isAgent ? (
<Bot
aria-label="agent"
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
) : null}
</div>
);
}

View File

@@ -16,10 +16,10 @@ export function UserProfileSnapshotExportDialog({
return (
<AgentSnapshotExportDialog
agentName={persona.displayName}
isSavePending={exportSnapshotMutation.isPending}
linkedAgentPubkey={linkedAgentPubkey}
open
persona={persona}
onOpenChange={onOpenChange}
onSaveFile={(memoryLevel, format) => {
exportSnapshotMutation.mutate(

View File

@@ -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<void> {
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.

View File

@@ -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}"`);
}
});

View File

@@ -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) {

View File

@@ -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 <span className="font-medium text-current">{children}</span>;
}
@@ -1365,8 +1370,10 @@ function createMarkdownComponents(
if (snapshotCard) {
return (
<AgentSnapshotCard
displayName={snapshotCard.displayName}
href={snapshotCard.href}
filename={snapshotCard.filename}
sharedBy={snapshotSharedBy}
size={snapshotCard.size}
sha256={snapshotCard.sha256}
snapshotKind={snapshotCard.snapshotKind}
@@ -1822,6 +1829,7 @@ function MarkdownInner({
mentionNames,
mentionPubkeysByName,
searchQuery,
snapshotSharedBy,
videoReviewContext,
}: MarkdownProps) {
const { channels: rawChannels } = useChannelNavigation();
@@ -1865,6 +1873,7 @@ function MarkdownInner({
mentionPubkeysByName,
onOpenChannel,
onOpenMessageLink,
snapshotSharedBy,
onImportSnapshotFromUrl: (
fileBytes: number[],
fileName: string,
@@ -1881,6 +1890,7 @@ function MarkdownInner({
mentionPubkeysByName,
onOpenChannel,
onOpenMessageLink,
snapshotSharedBy,
goAgents,
],
);
@@ -1984,6 +1994,7 @@ export const Markdown = React.memo(
prev.imetaByUrl === next.imetaByUrl &&
prev.configNudgeAuthorPubkey === next.configNudgeAuthorPubkey &&
prev.searchQuery === next.searchQuery &&
prev.snapshotSharedBy === next.snapshotSharedBy &&
prev.videoReviewContext === next.videoReviewContext,
);
Markdown.displayName = "Markdown";

View File

@@ -1,13 +1,24 @@
import * as React from "react";
import { AlertCircle, Bot, Download, Loader2 } from "lucide-react";
import { Bot, Download, Loader2, Users } from "lucide-react";
import { invokeTauri } from "@/shared/api/tauri";
import { fetchSnapshotBytes } from "@/shared/api/tauriMedia";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import { cn } from "@/shared/lib/cn";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/shared/ui/attachment";
export type AgentSnapshotCardProps = {
displayName: string;
href: string;
filename: string;
sharedBy?: string;
size?: number;
sha256: string;
/** Discriminant used to label the card and route the import. */
@@ -34,29 +45,28 @@ type ImportState =
| { phase: "error"; message: string };
/**
* Snapshot attachment card rendered in a message timeline when an `.agent.json`
* or `.agent.png` attachment is classified as an agent snapshot candidate.
* Snapshot attachment card rendered in a message timeline when an agent or
* team snapshot attachment is classified as an importable snapshot candidate.
*
* Shows two independent actions:
* - **Import agent** — bounded, verified in-memory fetch → existing importer
* - **Add agent/team** — bounded, verified in-memory fetch → existing importer
* - **Download** — native save dialog via the unchanged `download_file` command
*
* The card text identifies the attachment as "Agent snapshot" (untrusted label)
* until the bytes have been verified by the Rust decoder; the sender-supplied
* agent name is never shown here.
* The display name and sharer are presentation-only message metadata. Import
* still fetches and verifies the snapshot bytes before the existing preview
* flow presents the decoded snapshot details.
*/
export function AgentSnapshotCard({
displayName,
href,
filename,
sharedBy,
size,
sha256,
snapshotKind,
thumb,
onImport,
}: AgentSnapshotCardProps) {
const cardRef = React.useRef<HTMLDivElement | null>(null);
useSmoothCorners(cardRef);
const [importState, setImportState] = React.useState<ImportState>({
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 (
<div
ref={cardRef}
className="my-1 inline-flex max-w-sm flex-col gap-2 rounded-2xl border border-primary/25 bg-primary/5 px-3 py-2 text-left"
style={{ borderRadius: "1rem" }}
<Attachment
className="my-1 inline-flex w-fit max-w-full shadow-none"
data-testid="agent-snapshot-card"
state={importState.phase === "error" ? "error" : "done"}
>
{/* Header row: icon + filename */}
<div className="flex items-center gap-2">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary overflow-hidden">
{showThumb ? (
<AttachmentMedia
className={cn(
showThumb
? "relative h-9 w-9"
: "bg-primary/10 text-primary ring-1 ring-primary/20 dark:bg-primary/15",
)}
variant={showThumb ? "image" : "icon"}
>
{showThumb ? (
<>
<img
alt=""
className="h-full w-full object-cover"
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full scale-150 object-cover"
src={thumb}
referrerPolicy="no-referrer"
/>
<img
alt=""
className="relative h-full w-full object-cover"
data-testid="agent-snapshot-card-thumb"
src={thumb}
referrerPolicy="no-referrer"
onError={() => setThumbError(true)}
/>
) : (
<Bot className="h-4 w-4" />
)}
</span>
<div className="min-w-0 flex-1">
<span
className="block truncate text-sm font-medium text-foreground"
title={filename}
>
{filename}
</span>
<span className="block text-xs text-muted-foreground">
{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`}`
: ""}
</span>
</div>
</div>
{/* Error row */}
{importState.phase === "error" && (
<div
className="flex items-start gap-1.5 rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1 text-xs text-destructive"
data-testid="agent-snapshot-card-error"
</>
) : (
<SnapshotIcon />
)}
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle
className="overflow-visible whitespace-normal text-clip"
title={displayName}
>
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{importState.message}</span>
</div>
)}
{/* Action row */}
<div className="flex items-center gap-2">
<button
{displayName}
</AttachmentTitle>
{importState.phase === "error" ? (
<AttachmentDescription
className="overflow-visible whitespace-normal text-clip text-destructive"
data-testid="agent-snapshot-card-error"
>
{importState.message}
</AttachmentDescription>
) : metadata ? (
<AttachmentDescription className="overflow-visible whitespace-normal text-clip text-secondary-foreground/75">
{metadata}
</AttachmentDescription>
) : null}
</AttachmentContent>
<AttachmentActions
aria-label={`Actions for ${displayName}`}
className="ml-4 gap-2"
role="group"
>
<AttachmentAction
aria-label={`Download ${displayName}`}
data-testid="agent-snapshot-card-download"
onClick={handleDownload}
size="icon"
title="Download"
type="button"
variant="ghost"
>
<Download />
</AttachmentAction>
<AttachmentAction
className="text-primary-foreground shadow-none hover:bg-primary/90 hover:text-primary-foreground hover:shadow-none"
data-testid="agent-snapshot-card-import"
disabled={isFetching}
onClick={handleImport}
className="flex h-7 flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
data-testid="agent-snapshot-card-import"
>
{isFetching ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Bot className="h-3.5 w-3.5" />
)}
{isFetching
? "Fetching…"
: snapshotKind === "team"
? "Import team"
: "Import agent"}
</button>
<button
size="sm"
type="button"
onClick={handleDownload}
className="flex h-7 items-center justify-center gap-1 rounded-lg border border-border bg-background px-3 text-xs font-medium text-foreground transition-colors hover:bg-muted"
data-testid="agent-snapshot-card-download"
variant="default"
>
<Download className="h-3 w-3" />
Download
</button>
</div>
</div>
{isFetching ? <Loader2 className="animate-spin" /> : <SnapshotIcon />}
{isFetching
? "Loading…"
: snapshotKind === "team"
? "Add team"
: "Add agent"}
</AttachmentAction>
</AttachmentActions>
</Attachment>
);
}

View File

@@ -32,6 +32,8 @@ export type MarkdownRuntime = {
mentionPubkeysByName?: Record<string, string>;
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<string, string>;
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

View File

@@ -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", () => {

View File

@@ -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,
};
}

View File

@@ -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<typeof handleGetEvent>[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;

View File

@@ -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 });

View File

@@ -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");
// Must show filename.
await expect(card).toContainText("e2e-agent.agent.json");
// 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 "Agent snapshot" label (untrusted until decode).
await expect(card).toContainText("Agent snapshot");
const mediaBox = await card
.locator('[data-slot="attachment-media"]')
.boundingBox();
expect(mediaBox?.width).toBe(mediaBox?.height);
// 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).

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,9 @@ import { expect, test } from "@playwright/test";
import {
installMockBridge,
createMockAgentMemoryListing,
TEST_IDENTITIES,
} from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -221,9 +223,9 @@ test("team_snapshot_import_keep_allowlist_sends_keepAllowlist_true", async ({
expect(confirmPayload?.input?.keepAllowlist).toBe(true);
});
// ── (c) Memory gate ordering ────────────────────────────────────────────────
// ── (c) Sharing parity ──────────────────────────────────────────────────────
test("team_snapshot_send_memory_gate_blocks_encode_until_confirmed", async ({
test("team sharing uses the people picker and gates memory before sending", async ({
page,
}) => {
await installMockBridge(page, {
@@ -243,76 +245,76 @@ test("team_snapshot_send_memory_gate_blocks_encode_until_confirmed", async ({
},
],
agentMemory: createMockAgentMemoryListing(),
searchProfiles: [
{
pubkey: TEST_IDENTITIES.charlie.pubkey,
displayName: "Charlie",
},
],
uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR],
});
await gotoAgentsPage(page);
// Open export dialog for the "Engineering" seeded team.
await page.getByLabel("Engineering team actions").click();
await page.getByRole("menuitem", { name: "Export snapshot" }).click();
await page.getByRole("menuitem", { name: "Share" }).click();
const shareDialog = page.getByTestId("team-share-dialog");
await expect(shareDialog).toBeVisible();
await expect(
shareDialog.getByRole("heading", { name: "Share Engineering" }),
).toBeVisible();
// Select memory level = "core".
const coreOption = page.getByRole("radio", {
name: "Config + core memory",
});
if (await coreOption.isVisible()) {
await coreOption.click();
}
// Click "Send in Buzz" to open the send dialog.
await page.getByTestId("team-snapshot-send-in-buzz").click();
await expect(page.getByTestId("team-snapshot-send-dialog")).toBeVisible();
// Select #general.
const search = shareDialog.getByTestId("team-share-recipient-search");
await expect(search).toBeEnabled({ timeout: 5_000 });
await search.fill("charlie");
await page
.getByTestId("team-snapshot-send-channel-list")
.getByText("general")
.getByTestId(
`team-share-recipient-option-${TEST_IDENTITIES.charlie.pubkey}`,
)
.click();
await shareDialog.getByTestId("team-share-recipient-access").click();
await page.getByRole("menuitemradio", { name: "Team + core memory" }).click();
await shareDialog.getByTestId("team-share-send").click();
// Click Next (memory-bearing send shows "Next" instead of "Send").
await page.getByTestId("team-snapshot-send-confirm").click();
const memoryConfirmation = page.getByTestId("team-share-memory-confirmation");
await expect(memoryConfirmation).toBeVisible();
await expect(memoryConfirmation).toContainText("This team includes");
await expect(memoryConfirmation).toContainText("plaintext core memory");
const logBeforeConfirmation = await readCommandLog(page);
const encodeLevelsBeforeConfirmation = logBeforeConfirmation
.filter((entry) => entry.command === "encode_team_snapshot_for_send")
.map(
(entry) =>
(entry.payload as { memoryLevel?: string } | undefined)?.memoryLevel,
);
expect(encodeLevelsBeforeConfirmation).toEqual([]);
// The memory gate step must be visible.
const memGate = page.getByTestId("team-snapshot-send-memgate-confirm");
await expect(memGate).toBeVisible({ timeout: 3000 });
// At this point NO encode should have happened — the gate blocks it.
const logBeforeGate = await readCommandLog(page);
const encodeBeforeGate = logBeforeGate.filter(
(e) => e.command === "encode_team_snapshot_for_send",
);
expect(encodeBeforeGate).toHaveLength(0);
// Confirm the memory gate — "Send anyway".
await memGate.click();
// Wait for done.
await expect(page.getByTestId("team-snapshot-send-done")).toBeVisible({
timeout: 8000,
await memoryConfirmation.getByTestId("team-share-memory-confirm").click();
await expect(page.getByText("Sent a copy of Engineering")).toBeVisible({
timeout: 8_000,
});
// Now encode must have been called.
const logAfterGate = await readCommandLog(page);
const encodeAfterGate = logAfterGate.filter(
(e) => e.command === "encode_team_snapshot_for_send",
);
expect(encodeAfterGate).toHaveLength(1);
// The sent message content must use a file-link, not an inline image —
// a .team.png rendered as ![image](...) produces a blank/invisible message
// because the PNG body is a 1×1 placeholder.
const sendEntry = logAfterGate.find(
(e) => e.command === "send_channel_message",
const log = await readCommandLog(page);
expect(
log.filter(
(entry) =>
entry.command === "encode_team_snapshot_for_send" &&
(entry.payload as { memoryLevel?: string } | undefined)?.memoryLevel ===
"core",
),
).toHaveLength(1);
const sendEntry = log.find(
(entry) => entry.command === "send_channel_message",
);
expect(sendEntry).toBeTruthy();
const sendPayload = sendEntry?.payload as { content?: string } | undefined;
expect(sendPayload?.content).toContain("[e2e-team.team.png](");
expect(sendPayload?.content).toContain("[Engineering](");
expect(sendPayload?.content).not.toContain("![image](");
});
test("team_snapshot_send_none_memory_skips_gate_and_encodes_directly", async ({
test("team sharing keeps link copy and export in the shared surface", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await installMockBridge(page, {
personas: [
{
@@ -329,49 +331,139 @@ test("team_snapshot_send_none_memory_skips_gate_and_encodes_directly", async ({
},
],
uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR],
uploadDelayMs: 800,
});
await gotoAgentsPage(page);
// Open export dialog for the "Engineering" seeded team.
await page.getByLabel("Engineering team actions").click();
await page.getByRole("menuitem", { name: "Export snapshot" }).click();
const menu = page.getByRole("menu");
await expect(
menu.getByRole("menuitem", { name: "Export snapshot" }),
).toHaveCount(0);
await menu.getByRole("menuitem", { name: "Share" }).click();
// Memory level defaults to "none" — no need to change it.
const shareDialog = page.getByTestId("team-share-dialog");
await expect(shareDialog.getByTestId("team-share-link-access")).toHaveText(
"Team only",
);
const exportTeamRow = shareDialog.getByTestId("team-share-export");
const copyLinkButton = shareDialog.getByTestId("team-share-copy-link");
const recipientSearch = shareDialog.getByTestId(
"team-share-recipient-search",
);
const linkAccess = shareDialog.getByTestId("team-share-link-access");
const closeButton = shareDialog.getByRole("button", { name: "Close" });
await waitForAnimations(page);
await expect(
copyLinkButton.getByTestId("team-share-copy-link-stage"),
).toHaveCSS("position", "relative");
const idleCopyLinkButtonBox = await copyLinkButton.boundingBox();
await copyLinkButton.click();
await expect(copyLinkButton.locator(".sprout-arc-spinner")).toBeVisible();
await expect(copyLinkButton).toContainText("Copying…");
await expect(copyLinkButton).toHaveCSS("opacity", "1");
await expect(recipientSearch).toBeEnabled();
await expect(linkAccess).toBeEnabled();
await expect(closeButton).toBeDisabled();
await expect(exportTeamRow).toBeDisabled();
await expect(exportTeamRow).toHaveCSS("opacity", "1");
await page.keyboard.press("Escape");
await expect(shareDialog).toBeVisible();
await expect(copyLinkButton).toContainText("Copied");
await expect(copyLinkButton).toHaveAttribute("data-copy-status", "copied");
await expect(closeButton).toBeEnabled();
await expect(exportTeamRow).toBeEnabled();
await waitForAnimations(page);
const copiedButtonBox = await copyLinkButton.boundingBox();
const copiedStateColors = await copyLinkButton
.getByTestId("team-share-copy-link-state")
.evaluate((element) => {
const icon = element.querySelector("svg");
const label = element.querySelector("span");
return {
icon: icon ? getComputedStyle(icon).color : null,
label: label ? getComputedStyle(label).color : null,
};
});
expect(copiedStateColors.icon).toBe(copiedStateColors.label);
expect(copiedButtonBox?.width).toBeLessThan(
idleCopyLinkButtonBox?.width ?? 0,
);
expect(
Math.abs(
(copiedButtonBox?.x ?? 0) +
(copiedButtonBox?.width ?? 0) -
((idleCopyLinkButtonBox?.x ?? 0) + (idleCopyLinkButtonBox?.width ?? 0)),
),
).toBeLessThanOrEqual(1);
await expect(copyLinkButton).toContainText("Copy link", { timeout: 4_000 });
await expect(copyLinkButton).toHaveAttribute("data-copy-status", "idle");
// Click "Send in Buzz".
await page.getByTestId("team-snapshot-send-in-buzz").click();
await expect(page.getByTestId("team-snapshot-send-dialog")).toBeVisible();
const log = await readCommandLog(page);
const encodeEntry = log.find(
(entry) => entry.command === "encode_team_snapshot_for_send",
);
expect(encodeEntry?.payload).toEqual(
expect.objectContaining({ memoryLevel: "none", format: "png" }),
);
// Select #general.
await page
.getByTestId("team-snapshot-send-channel-list")
.getByText("general")
.click();
// Click Send (no "Next" for none-memory — goes straight to send).
await page.getByTestId("team-snapshot-send-confirm").click();
// The memory gate must NOT appear — should go directly to progress/done.
const memGateBtn = page.getByTestId("team-snapshot-send-memgate-confirm");
// Give a brief window to ensure memgate doesn't appear.
await expect(memGateBtn).toHaveCount(0);
// Wait for done.
await expect(page.getByTestId("team-snapshot-send-done")).toBeVisible({
timeout: 8000,
const copiedTeam = await page.evaluate(() => {
const commands =
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: { html?: string; text?: string };
}>;
}
).__BUZZ_E2E_COMMAND_LOG__ ?? [];
return commands.findLast(
(entry) => entry.command === "copy_text_to_clipboard",
)?.payload;
});
// Encode must have been called directly (no gate).
const log = await readCommandLog(page);
const encodeEntries = log.filter(
(e) => e.command === "encode_team_snapshot_for_send",
);
expect(encodeEntries).toHaveLength(1);
await page.keyboard.press("Escape");
await expect(shareDialog).toHaveCount(0);
await page.getByTestId("channel-general").click();
await page.evaluate(async ({ html, text }) => {
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob([html ?? ""], { type: "text/html" }),
"text/plain": new Blob([text ?? ""], { type: "text/plain" }),
}),
]);
}, copiedTeam ?? {});
await page
.getByTestId("message-composer")
.locator("[contenteditable='true']")
.click();
await page.keyboard.press("ControlOrMeta+V");
// The sent message content must use a file-link, not an inline image.
const sendEntry = log.find((e) => e.command === "send_channel_message");
expect(sendEntry).toBeTruthy();
const sendPayload = sendEntry?.payload as { content?: string } | undefined;
expect(sendPayload?.content).toContain("[e2e-team.team.png](");
expect(sendPayload?.content).not.toContain("![image](");
const composerTeamCard = page.getByTestId("composer-team-snapshot-card");
await expect(composerTeamCard).toBeVisible();
await expect(composerTeamCard).toContainText("Engineering");
await expect(composerTeamCard.locator("img")).toHaveCount(0);
await page.getByTestId("send-message").click();
const sentTeamCard = page.getByTestId("agent-snapshot-card").last();
await expect(sentTeamCard).toBeVisible();
await expect(sentTeamCard).toContainText("Engineering");
await expect(sentTeamCard).toContainText("Add team");
await expect(sentTeamCard.locator("img")).toHaveCount(0);
await page.getByTestId("open-agents-view").click();
await page.getByLabel("Engineering team actions").click();
await page.getByRole("menuitem", { name: "Share" }).click();
await page.getByTestId("team-share-export").click();
const exportDialog = page.getByTestId("team-snapshot-export-dialog");
await expect(exportDialog).toBeVisible();
await expect(
exportDialog.getByRole("heading", { name: "Export Engineering" }),
).toBeVisible();
await expect(
exportDialog.getByTestId("team-snapshot-memory-trigger"),
).toHaveText("Team only");
await expect(
exportDialog.getByTestId("team-snapshot-format-trigger"),
).toHaveText("PNG");
});