mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
Polish agent and team sharing and snapshots (#1852)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
309
desktop/src/features/agents/ui/PersonaShareRecipients.tsx
Normal file
309
desktop/src/features/agents/ui/PersonaShareRecipients.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
desktop/src/features/agents/ui/SnapshotOptionMenu.tsx
Normal file
75
desktop/src/features/agents/ui/SnapshotOptionMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
desktop/src/features/agents/ui/TeamShareDialog.tsx
Normal file
42
desktop/src/features/agents/ui/TeamShareDialog.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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]]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 <Agent> "One"/);
|
||||
assert.match(html, /x=1&y=2/);
|
||||
});
|
||||
|
||||
test("invalid copied snapshot metadata falls through to normal paste", () => {
|
||||
const oversizedTeamHtml = buildTeamSnapshotClipboardHtml({
|
||||
attachment: {
|
||||
filename: "design-review.team.png",
|
||||
sha256: SHA256,
|
||||
size: 51 * 1024 * 1024,
|
||||
type: "image/png",
|
||||
uploaded: 1,
|
||||
url: URL,
|
||||
},
|
||||
displayName: "Design Review",
|
||||
});
|
||||
const invalid = [
|
||||
buildHtml({ sha256: "short" }),
|
||||
buildHtml({ filename: "not-an-agent.png" }),
|
||||
buildHtml({ size: 11 * 1024 * 1024 }),
|
||||
oversizedTeamHtml,
|
||||
buildHtml({ type: "text/html" }),
|
||||
buildHtml({ url: "javascript:alert(1)" }),
|
||||
buildHtml({ url: `${URL})[hidden](https://attacker.example` }),
|
||||
buildHtml({ url: `${URL}\n[hidden](https://attacker.example)` }),
|
||||
buildHtml({ url: `${URL} trailing` }),
|
||||
];
|
||||
|
||||
for (const html of invalid) {
|
||||
assert.equal(parseAgentSnapshotClipboardHtml(html), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("malformed copied snapshot field types fall through to normal paste", () => {
|
||||
const validPayload = {
|
||||
version: 1,
|
||||
displayName: "Animation Auditor",
|
||||
filename: "animation-auditor.agent.png",
|
||||
sha256: SHA256,
|
||||
size: 1234,
|
||||
type: "image/png",
|
||||
url: URL,
|
||||
};
|
||||
|
||||
for (const override of [
|
||||
{ displayName: 5 },
|
||||
{ filename: { value: "animation-auditor.agent.png" } },
|
||||
{ sha256: [SHA256] },
|
||||
]) {
|
||||
const encodedPayload = encodeURIComponent(
|
||||
JSON.stringify({ ...validPayload, ...override }),
|
||||
);
|
||||
const html = `<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);
|
||||
});
|
||||
177
desktop/src/features/messages/lib/agentSnapshotClipboard.ts
Normal file
177
desktop/src/features/messages/lib/agentSnapshotClipboard.ts
Normal 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("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function isSafeSnapshotUrl(value: string): boolean {
|
||||
// The raw value is later emitted inside Markdown's `(url)` syntax. Keep
|
||||
// delimiters and whitespace out even when URL parsing would normalize them.
|
||||
if (/[\s()]/u.test(value)) return false;
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" || url.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build Buzz-specific clipboard HTML with the raw URL as its visible link. */
|
||||
export function buildSnapshotClipboardHtml({
|
||||
attachment,
|
||||
displayName,
|
||||
snapshotKind,
|
||||
}: {
|
||||
attachment: ImetaMedia;
|
||||
displayName: string;
|
||||
snapshotKind: "agent" | "team";
|
||||
}): string {
|
||||
const payload: SnapshotClipboardPayload = {
|
||||
version: 1,
|
||||
displayName,
|
||||
filename: attachment.filename ?? `shared.${snapshotKind}.png`,
|
||||
sha256: attachment.sha256,
|
||||
size: attachment.size,
|
||||
type: attachment.type,
|
||||
url: attachment.url,
|
||||
};
|
||||
const encodedPayload = encodeURIComponent(JSON.stringify(payload));
|
||||
|
||||
return `<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;
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -534,6 +534,7 @@ export function collectMessageAuthorPubkeys(events: RelayEvent[]) {
|
||||
pubkeys.add(pk);
|
||||
}
|
||||
} else {
|
||||
pubkeys.add(event.pubkey.toLowerCase());
|
||||
pubkeys.add(
|
||||
resolveEventAuthorPubkey({
|
||||
pubkey: event.pubkey,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = ``;
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
31
desktop/src/features/messages/lib/snapshotSharedBy.test.mjs
Normal file
31
desktop/src/features/messages/lib/snapshotSharedBy.test.mjs
Normal 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);
|
||||
});
|
||||
18
desktop/src/features/messages/lib/snapshotSharedBy.ts
Normal file
18
desktop/src/features/messages/lib/snapshotSharedBy.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 `` 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(
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
130
desktop/src/features/profile/ui/SelectedRecipientChip.tsx
Normal file
130
desktop/src/features/profile/ui/SelectedRecipientChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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  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(";
|
||||
});
|
||||
|
||||
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(";
|
||||
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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user