- {types.length === 0 && (
+ {/* Laid out as the agent's Identity tab will show it once imported. */}
+
+ {types.length === 0 ? (
Enable an agent type plugin to import agents.
- )}
- {initial && !changing ? (
- <>
-
-
- {shared.provider && (
-
- )}
+ ) : (
+
+ ({
+ value: item.key,
+ label: item.title,
+ })),
+ },
+ ]}
+ onValueChange={choose}
+ />
+ }
+ />
+ {Model && (
-
- {
- // The button goes with the summary; keep focus on what replaces it.
- flushSync(() => setChanging(true));
- form.current
- ?.querySelector("[role='combobox']")
- ?.focus();
- }}
- >
- Change settings
-
- >
- ) : (
- types.length > 0 && (
- <>
- ({
- value: item.key,
- label: item.title,
- })),
- },
- ]}
- onValueChange={choose}
- />
- {Model && (
-
-
- }
+ trailing={
+
-
- )}
- >
- )
+ }
+ />
+ )}
+
)}
- {/* What the new agent will do is never hidden behind a summary. */}
-
-
-
- {shared.instructions || "No system prompt included."}
-
-
+
{(refused || error) && (
{refused || error}
diff --git a/src/bundled/agents/AgentCardAttachment.module.css b/src/bundled/agents/AgentCardAttachment.module.css
new file mode 100644
index 0000000000..9f0ba2a69d
--- /dev/null
+++ b/src/bundled/agents/AgentCardAttachment.module.css
@@ -0,0 +1,11 @@
+/* One card in a message keeps the Agents page card's size; the card fills
+ its frame as it fills a grid track there. */
+.frame {
+ width: 14rem;
+ max-width: 100%;
+}
+.picture {
+ display: block;
+ max-width: min(100%, 20rem);
+ border-radius: var(--radius-card);
+}
diff --git a/src/bundled/agents/AgentCardAttachment.test.tsx b/src/bundled/agents/AgentCardAttachment.test.tsx
new file mode 100644
index 0000000000..56b68465c4
--- /dev/null
+++ b/src/bundled/agents/AgentCardAttachment.test.tsx
@@ -0,0 +1,245 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing";
+stubAvatarBrowserApis();
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ within,
+} from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, expect, it, vi } from "vitest";
+import { MAX_PNG_BYTES, snapshotPng } from "../../features/agents/agent-share";
+import type {
+ Agent,
+ Agents,
+ RegisteredAgentType,
+} from "../../features/agents/service";
+import type { Navigation } from "../../features/navigation/controller";
+import type { Attachment } from "../../features/relay/contracts";
+import type { RelayData } from "../../features/relay/service";
+import { ToastProvider } from "../../shared/design-system/ui/Toast";
+import { AgentCardAttachment, isAgentCard } from "./AgentCardAttachment";
+import { agentTarget } from "./routes";
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+const echo = {
+ key: "test/echo",
+ title: "Echo",
+ importShared: (source: { model: string }) => ({ model: source.model }),
+} as unknown as RegisteredAgentType;
+const snapshot = { agents: [] };
+const types = [echo];
+const agents = {
+ subscribe: () => () => {},
+ snapshot: () => snapshot,
+ types: () => types,
+ port: vi.fn(async () => ({
+ agent: { pubkey: "e".repeat(64), name: "Ada" } as Agent,
+ notes: [],
+ })),
+} as unknown as Agents & { port: ReturnType };
+const connection = { status: "ready", origin: "https://relay.example" };
+const relay = {
+ snapshot: () => connection,
+ subscribe: () => () => {},
+} as unknown as RelayData;
+const card: Attachment = {
+ url: "https://relay.example/media/ada.png",
+ kind: "image",
+ mime: "image/png",
+ name: "Ada.agent.png",
+};
+
+/** A response streaming `chunks`, which counts what was read and whether it
+ * was cancelled. jsdom's Blob doesn't survive Node's Response. */
+const stream = (chunks: Uint8Array[], length?: number) => {
+ const read = { bytes: 0, cancelled: false };
+ let next = 0;
+ const body = new ReadableStream({
+ pull(controller) {
+ const chunk = chunks[next++];
+ if (!chunk) return controller.close();
+ read.bytes += chunk.byteLength;
+ controller.enqueue(chunk);
+ },
+ cancel() {
+ read.cancelled = true;
+ },
+ });
+ const headers = new Headers(
+ length === undefined ? {} : { "content-length": String(length) },
+ );
+ return { response: { ok: true, status: 200, headers, body }, read };
+};
+
+/** Media that answers with `body`. */
+const serve = async (body: Blob) => {
+ const bytes = new Uint8Array(await body.arrayBuffer());
+ const fetch = vi.fn(async () => stream([bytes], bytes.byteLength).response);
+ vi.stubGlobal("fetch", fetch);
+ return fetch;
+};
+const ada = () =>
+ snapshotPng({
+ name: "Ada",
+ instructions: "Review pull requests.",
+ runtime: "echo",
+ model: "echo-1",
+ });
+
+const show = (attachment = card) =>
+ render(
+ false}
+ open={() => false}
+ />,
+ );
+
+it("claims only the agent cards Share sends", () => {
+ expect(isAgentCard(card)).toBe(true);
+ expect(isAgentCard({ ...card, name: "Ada.png" })).toBe(false);
+ expect(isAgentCard({ ...card, kind: "file" })).toBe(false);
+});
+
+it("shows a sent agent as its card, and adds it from there", async () => {
+ const user = userEvent.setup();
+ const fetch = await serve(
+ snapshotPng({
+ name: "Ada",
+ instructions: "Review pull requests.",
+ runtime: "echo",
+ model: "echo-1",
+ }),
+ );
+ show();
+ const button = await screen.findByRole("button", {
+ name: "Ada, shared agent",
+ });
+ await vi.waitFor(() => expect(button).toBeEnabled());
+ expect(fetch).toHaveBeenCalledWith("buzz-media://ada.png", expect.anything());
+ expect(within(button).getByText("echo-1")).toBeInTheDocument();
+ await user.click(button);
+ const dialog = screen.getByRole("dialog", { name: "Ada" });
+ // What it will follow can be changed before it is added.
+ const instructions = within(dialog).getByRole("textbox", {
+ name: "Instructions",
+ });
+ await user.clear(instructions);
+ await user.type(instructions, "Review small pull requests.");
+ await user.click(within(dialog).getByRole("button", { name: "Import" }));
+ await vi.waitFor(() =>
+ expect(agents.port).toHaveBeenCalledWith(
+ expect.objectContaining({
+ source: "shared",
+ name: "Ada",
+ instructions: "Review small pull requests.",
+ }),
+ ),
+ );
+});
+
+it("shows the picture when the file holds no agent", async () => {
+ await serve(new Blob(["not a card"], { type: "image/png" }));
+ show();
+ expect(await screen.findByRole("img", { name: "Ada" })).toHaveAttribute(
+ "src",
+ "buzz-media://ada.png",
+ );
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+});
+
+it("names what it added, and opens it from there", async () => {
+ const user = userEvent.setup();
+ await serve(
+ snapshotPng({
+ name: "Ada",
+ instructions: "Review pull requests.",
+ runtime: "echo",
+ model: "echo-1",
+ }),
+ );
+ const navigator = { open: vi.fn(async () => ({})) };
+ render(
+ false}
+ open={() => false}
+ />,
+ { wrapper: ToastProvider },
+ );
+ const button = await screen.findByRole("button", {
+ name: "Ada, shared agent",
+ });
+ await vi.waitFor(() => expect(button).toBeEnabled());
+ await user.click(button);
+ await user.click(
+ within(screen.getByRole("dialog", { name: "Ada" })).getByRole("button", {
+ name: "Import",
+ }),
+ );
+ expect(
+ await screen.findByText("Added Ada to your agents."),
+ ).toBeInTheDocument();
+ // sonner's pointer capture is missing in jsdom.
+ fireEvent.click(screen.getByRole("button", { name: "Open" }));
+ expect(navigator.open).toHaveBeenCalledExactlyOnceWith(
+ agentTarget("e".repeat(64)),
+ );
+});
+
+it("refuses a card larger than the limit without reading it all", async () => {
+ const chunk = new Uint8Array(1024 * 1024);
+ const chunks = Array.from({ length: 20 }, () => chunk);
+ const declared = stream(chunks, MAX_PNG_BYTES + 1);
+ const chunked = stream(chunks);
+ for (const { response, read } of [declared, chunked]) {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => response),
+ );
+ const view = show();
+ expect(await screen.findByRole("img", { name: "Ada" })).toBeVisible();
+ expect(read.cancelled).toBe(true);
+ // The stream may pull one chunk ahead of the reader.
+ expect(read.bytes).toBeLessThanOrEqual(
+ MAX_PNG_BYTES + 2 * chunk.byteLength,
+ );
+ view.unmount();
+ }
+ // Refused by its length before reading, past what the stream pulls ahead.
+ expect(declared.read.bytes).toBeLessThanOrEqual(chunk.byteLength);
+});
+
+it("reads a card again when chosen after its read failed", async () => {
+ const user = userEvent.setup();
+ const bytes = new Uint8Array(await ada().arrayBuffer());
+ const fetch = vi
+ .fn()
+ .mockRejectedValueOnce(new TypeError("Failed to fetch"))
+ .mockImplementation(async () => stream([bytes]).response);
+ vi.stubGlobal("fetch", fetch);
+ show();
+ const button = await screen.findByRole("button", {
+ name: "Ada, shared agent. Couldn’t load. Try again",
+ });
+ expect(within(button).getByText("Couldn’t load. Try again")).toBeVisible();
+ expect(screen.queryByRole("img", { name: "Ada" })).not.toBeInTheDocument();
+ await user.click(button);
+ expect(await screen.findByRole("dialog", { name: "Ada" })).toBeVisible();
+ expect(fetch).toHaveBeenCalledTimes(2);
+});
diff --git a/src/bundled/agents/AgentCardAttachment.tsx b/src/bundled/agents/AgentCardAttachment.tsx
new file mode 100644
index 0000000000..e988b40de7
--- /dev/null
+++ b/src/bundled/agents/AgentCardAttachment.tsx
@@ -0,0 +1,162 @@
+// An agent card sent in a conversation, drawn as the agent's card from the
+// Marketplace: choosing it opens Add rather than the picture.
+import { useEffect, useRef, useState } from "react";
+import { MAX_PNG_BYTES, readSnapshot } from "../../features/agents/agent-share";
+import { useAgents } from "../../features/agents/react";
+import type { Agent, Agents } from "../../features/agents/service";
+import type { AttachmentRendererProps } from "../../features/conversation/contracts";
+import type { Navigation } from "../../features/navigation/controller";
+import type { Attachment } from "../../features/relay/contracts";
+import { useRelayConnection } from "../../features/relay/react";
+import type { RelayData } from "../../features/relay/service";
+import { SurfaceButton } from "../../shared/design-system/ui/SurfaceButton";
+import { useToastNotification } from "../../shared/design-system/ui/Toast";
+import { SharedAgentCardContent, SharedImportDialog } from "./AgentBrowse";
+import cardStyles from "./AgentCard.module.css";
+import styles from "./AgentCardAttachment.module.css";
+import { agentTarget } from "./routes";
+
+const CARD = /\.agent\.png$/i;
+
+/** Share names agent cards `.agent.png`. */
+export const isAgentCard = (attachment: Attachment) =>
+ attachment.kind === "image" && CARD.test(attachment.name ?? "");
+
+type Definition = Awaited>;
+
+/** What a card holds no agent in, as opposed to a read that failed. */
+class NotACard extends Error {}
+
+/** The card's bytes, refused past the card limit as they arrive: a message
+ * shows its card unasked, so its sender can't make it read more. */
+async function cardBytes(response: Response) {
+ if (Number(response.headers.get("content-length")) > MAX_PNG_BYTES) {
+ await response.body?.cancel();
+ throw new NotACard();
+ }
+ const reader = response.body?.getReader();
+ if (!reader) throw new Error("No body");
+ const chunks: Uint8Array[] = [];
+ let size = 0;
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) return new Blob(chunks, { type: "image/png" });
+ size += value.byteLength;
+ if (size > MAX_PNG_BYTES) {
+ await reader.cancel();
+ throw new NotACard();
+ }
+ chunks.push(value);
+ }
+}
+
+export function AgentCardAttachment({
+ attachment,
+ source,
+ agents,
+ relay,
+ navigator,
+}: AttachmentRendererProps & {
+ agents: Agents;
+ relay: RelayData;
+ /** Opens an agent of yours on the Agents page; absent, nothing offers to. */
+ navigator?: Navigation | undefined;
+}) {
+ const connection = useRelayConnection(relay);
+ const { types } = useAgents(agents);
+ // `undefined` while the card is read; `null` when it holds no agent.
+ const [shared, setShared] = useState();
+ // A read that failed, such as offline, is tried again when chosen.
+ const [failed, setFailed] = useState(false);
+ const [attempt, setAttempt] = useState(0);
+ const retried = useRef(false);
+ const [adding, setAdding] = useState(false);
+ const trigger = useRef(null);
+ const notify = useToastNotification();
+ const opener = (agent: Agent) =>
+ navigator && (() => void navigator.open(agentTarget(agent.pubkey)));
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Choosing a card whose read failed reads it again.
+ useEffect(() => {
+ const controller = new AbortController();
+ setShared(undefined);
+ setFailed(false);
+ fetch(source, { signal: controller.signal })
+ .then((response) => {
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return cardBytes(response);
+ })
+ .then((bytes) =>
+ readSnapshot(bytes).catch(() => {
+ throw new NotACard();
+ }),
+ )
+ .then(
+ (definition) => {
+ setShared(definition);
+ // It was chosen while it could not be read.
+ if (retried.current) setAdding(true);
+ retried.current = false;
+ },
+ (reason) => {
+ if (controller.signal.aborted) return;
+ retried.current = false;
+ if (reason instanceof NotACard) setShared(null);
+ else setFailed(true);
+ },
+ );
+ return () => controller.abort();
+ }, [source, attempt]);
+ const name = (attachment.name ?? "").replace(CARD, "") || "Agent";
+ // Not a card after all: show the picture it is.
+ if (shared === null)
+ return ;
+ const runnable = types.filter((type) => !type.remote);
+ return (
+
+ {
+ if (!failed) return setAdding(true);
+ retried.current = true;
+ setAttempt((value) => value + 1);
+ }}
+ >
+
+
+ {adding && shared && (
+ {
+ setAdding(false);
+ const show = opener(agent);
+ notify(
+ `Added ${agent.name} to your agents.`,
+ "success",
+ show && { label: "Open", onClick: show },
+ );
+ }}
+ {...(navigator && {
+ onOpen: (agent: Agent) => {
+ setAdding(false);
+ opener(agent)?.();
+ },
+ })}
+ onClose={() => setAdding(false)}
+ finalFocus={trigger}
+ />
+ )}
+
+ );
+}
diff --git a/src/bundled/agents/AgentShareDialog.module.css b/src/bundled/agents/AgentShareDialog.module.css
new file mode 100644
index 0000000000..382faaabe8
--- /dev/null
+++ b/src/bundled/agents/AgentShareDialog.module.css
@@ -0,0 +1,47 @@
+/* Search and the Marketplace stay put; only the results between them scroll,
+ as in Share access. */
+.layout {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ gap: var(--space-4);
+ /* When enlarged text leaves too little room, scroll the whole layout. */
+ overflow-y: auto;
+ /* Room for focus rings, which the scrolling layout would otherwise clip. */
+ --row-bleed: min(
+ var(--dialog-padding),
+ var(--space-control-inset) +
+ var(--space-1)
+ );
+ margin-inline: calc(-1 * var(--row-bleed));
+ padding-inline: var(--row-bleed);
+}
+.layout > * {
+ flex-shrink: 0;
+}
+/* Rows span the same width as the search field and the Marketplace group. */
+.results {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ /* A sticky heading plus one reachable row. */
+ min-height: calc(var(--size-row) * 2);
+ overflow: auto;
+ gap: var(--space-4);
+}
+.results > * {
+ flex-shrink: 0;
+}
+.groupHeading {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ padding-block: var(--space-2);
+ /* Text lines up with the rows' pictures. */
+ padding-inline: var(--space-2);
+ background: var(--surface-popover);
+}
+.note {
+ padding-inline: var(--space-2);
+}
diff --git a/src/bundled/agents/AgentShareDialog.test.tsx b/src/bundled/agents/AgentShareDialog.test.tsx
index 84f9e19a86..d33338b596 100644
--- a/src/bundled/agents/AgentShareDialog.test.tsx
+++ b/src/bundled/agents/AgentShareDialog.test.tsx
@@ -8,11 +8,13 @@ import {
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { afterEach, expect, it, vi } from "vitest";
+import { afterEach, assert, expect, it, vi } from "vitest";
import { readSnapshot } from "../../features/agents/agent-share";
+import { agentAvatars } from "../../features/agents/avatar-packs";
import type { Agent, RegisteredAgentType } from "../../features/agents/service";
import type { EventData } from "../../features/relay/events";
import type { RelaySession } from "../../features/relay/session";
+import { ToastProvider } from "../../shared/design-system/ui/Toast";
import { AgentShareDialog } from "./AgentShareDialog";
const desktop = vi.hoisted(() => ({ tauri: false, invoke: vi.fn() }));
@@ -21,6 +23,15 @@ vi.mock("@tauri-apps/api/core", () => ({
invoke: desktop.invoke,
}));
+const bea = { pubkey: "b".repeat(64), name: "Bea" };
+const cy = { pubkey: "c".repeat(64), name: "Cy" };
+const design = {
+ id: "d".repeat(36),
+ name: "design",
+ channelType: "stream",
+ members: ["f".repeat(64)],
+};
+
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
@@ -54,10 +65,17 @@ const echo = {
} as unknown as RegisteredAgentType;
/** A community whose catalog holds the heads its outbox accepted. */
+let communities = 0;
function community() {
const heads: EventData[] = [];
const listeners = new Set<() => void>();
let pending: { event: EventData; delivery: string } | undefined;
+ // Messages the outbox holds, kept across reloads as the journal is.
+ const journal: {
+ event: EventData;
+ delivery: string;
+ recovery?: { key: string; value: string } | undefined;
+ }[] = [];
const outbox = {
supports: (kind: number) => kind === 30175,
ready: async () => {},
@@ -71,12 +89,29 @@ function community() {
pending = { event, delivery: "sending" };
return event.id;
}),
- snapshot: () => (pending ? [pending] : []),
+ snapshot: () => [...journal, ...(pending ? [pending] : [])],
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
- dismiss: vi.fn(async () => {}),
+ // As the outbox does: a receipt is let go once delivered, and removed
+ // by hand only once it has definitively failed.
+ acknowledge: vi.fn(async (id: string) => {
+ const at = journal.findIndex((item) => item.event.id === id);
+ const item = journal[at];
+ if (!item?.recovery) return;
+ if (item.delivery !== "accepted" && item.delivery !== "seen")
+ throw new Error("Confirm delivery before completing this message");
+ journal.splice(at, 1);
+ }),
+ dismiss: vi.fn(async (id: string) => {
+ const at = journal.findIndex((item) => item.event.id === id);
+ if (journal[at]?.recovery && journal[at]?.delivery !== "failed")
+ throw new Error(
+ "Confirm this message in New message before removing it",
+ );
+ if (at >= 0) journal.splice(at, 1);
+ }),
};
/** The relay accepts what was sent. */
const accept = () => {
@@ -92,47 +127,166 @@ function community() {
for (const listener of listeners) listener();
};
const read = vi.fn(async () => [...heads]);
+ const channels = { channels: [design] };
+ const directMessages = {
+ available: true,
+ people: vi.fn(async () => ({
+ people: [
+ bea,
+ cy,
+ { pubkey: "e".repeat(64), name: "Echo", isAgent: true },
+ ],
+ hasMore: false,
+ })),
+ open: vi.fn(async ([pubkey]: string[]) => `dm-${pubkey?.[0]}`),
+ delivered: vi.fn(
+ async (_id: string, _channel: string, _signal?: AbortSignal) => {},
+ ),
+ delivery: vi.fn((_id: string) => "accepted"),
+ };
+ const attachments = {
+ upload: vi.fn(async (file: File, _channel: string) => ({
+ name: file.name,
+ url: "https://media.example/ada.agent.png",
+ type: file.type,
+ size: file.size,
+ sha256: "0".repeat(64),
+ })),
+ };
+ const messages = {
+ send: vi.fn(
+ (
+ channel: string,
+ _content: string,
+ _mentions: string[],
+ _attachments: unknown[],
+ recovery?: { key: string; value: string },
+ ) => {
+ const again = journal.filter((item) =>
+ item.event.id.startsWith(`sent-${channel}`),
+ ).length;
+ const id = `sent-${channel}${again ? `-${again}` : ""}`;
+ journal.push({
+ event: { id, kind: 9, tags: [["h", channel]] } as EventData,
+ delivery: "unknown",
+ recovery,
+ });
+ return id;
+ },
+ ),
+ };
return {
session: {
read,
outbox,
+ viewer: owner,
+ // Each test's community is its own.
+ scope: `https://relay${++communities}.example ${owner}`,
+ directMessages: {
+ ...directMessages,
+ // Confirming follows the outbox, which records the delivery.
+ delivered: async (id: string, channel: string, signal: AbortSignal) => {
+ await directMessages.delivered(id, channel, signal);
+ const item = journal.find((entry) => entry.event.id === id);
+ if (item) item.delivery = "accepted";
+ },
+ },
+ attachments,
+ messages,
+ channels: {
+ list: () => channels,
+ subscribeList: () => () => {},
+ },
media: (url: string) => url,
} as unknown as RelaySession,
heads,
outbox,
+ journal,
accept,
lose,
read,
+ directMessages,
+ attachments,
+ messages,
};
}
-it("shares the agent to its community's catalog and unshares what it shared", async () => {
+function share(session: RelaySession, overrides: Partial = {}) {
+ return render(
+ {}}
+ />,
+ { wrapper: ToastProvider },
+ );
+}
+
+/** The Marketplace switch, once the listing has been read. */
+async function listing() {
+ const toggle = await screen.findByRole("switch", {
+ name: /^Publish(ed)? to Marketplace$/,
+ });
+ await vi.waitFor(() =>
+ expect(toggle).not.toHaveAttribute("aria-disabled", "true"),
+ );
+ return toggle;
+}
+
+/** A person or channel among the results, ready to choose. */
+const choice = (name: string) =>
+ screen.findByRole("button", { name, pressed: false });
+
+/** The footer's export, which saves an image unless JSON is chosen. */
+const exportButton = (name = "Ada") =>
+ screen.getByRole("button", { name: `Export ${name} as an agent card` });
+
+const sharedContent = JSON.stringify({
+ display_name: "Ada",
+ system_prompt: "Review pull requests.",
+ acp_command: "buzz-acp",
+ runtime: "echo",
+ model: "echo-1",
+});
+
+const LISTING_NOTE =
+ "Anyone in this community can add a copy, instructions included. Memories and chats stay with you.";
+const toggle = () =>
+ screen.getByRole("switch", { name: /^Publish(ed)? to Marketplace$/ });
+/** Done applies what the switch asks for. */
+const done = (user: ReturnType) =>
+ user.click(screen.getByRole("button", { name: "Done" }));
+
+it("publishes on Done when switched on, and removes what it published when switched off", async () => {
const user = userEvent.setup();
const { session, outbox, accept } = community();
+ const onClose = vi.fn();
render(
{}}
+ onClose={onClose}
/>,
+ { wrapper: ToastProvider },
);
- const dialog = screen.getByRole("dialog", { name: "Share Ada" });
- const toggle = within(dialog).getByRole("switch", {
- name: "Share to catalog",
- });
- await vi.waitFor(() => expect(toggle).not.toHaveAttribute("aria-disabled"));
- expect(toggle).not.toBeChecked();
- await user.click(toggle);
+ expect(
+ await within(screen.getByRole("dialog", { name: "Share Ada" })).findByText(
+ LISTING_NOTE,
+ ),
+ ).toBeInTheDocument();
+ await user.click(await listing());
+ // The switch only asks; nothing is published until Done.
+ expect(toggle()).toBeChecked();
+ expect(toggle()).toHaveAccessibleName("Publish to Marketplace");
+ expect(outbox.send).not.toHaveBeenCalled();
+ await done(user);
await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(1));
- expect(toggle).toHaveAttribute("aria-disabled", "true");
- const sharedContent = JSON.stringify({
- display_name: "Ada",
- system_prompt: "Review pull requests.",
- acp_command: "buzz-acp",
- runtime: "echo",
- model: "echo-1",
- });
+ // The switch shows what it asked for while the relay answers.
+ expect(toggle()).toBeChecked();
+ expect(toggle()).toHaveAttribute("aria-disabled", "true");
+ expect(screen.getByText("Publishing…")).toBeInTheDocument();
expect(outbox.send).toHaveBeenLastCalledWith(
{
kind: 30175,
@@ -145,15 +299,22 @@ it("shares the agent to its community's catalog and unshares what it shared", as
undefined,
expect.any(Function),
);
+ expect(onClose).not.toHaveBeenCalled();
accept();
expect(
- await within(dialog).findByText("Published Ada to the community catalog."),
+ await screen.findByText("Published Ada to the Marketplace."),
).toBeInTheDocument();
- expect(toggle).toBeChecked();
+ await vi.waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
+ // Coming back, it says what it is.
+ expect(toggle()).toBeChecked();
+ expect(toggle()).toHaveAccessibleName("Published to Marketplace");
- await user.click(toggle);
+ await user.click(await listing());
+ expect(outbox.send).toHaveBeenCalledTimes(1);
+ await done(user);
await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(2));
- // Unsharing replaces the shared head with what it shared.
+ expect(screen.getByText("Removing…")).toBeInTheDocument();
+ // Removing replaces the shared head with what it shared.
expect(outbox.send).toHaveBeenLastCalledWith(
{
kind: 30175,
@@ -166,148 +327,165 @@ it("shares the agent to its community's catalog and unshares what it shared", as
);
accept();
expect(
- await within(dialog).findByText(
- "Ada is no longer discoverable in the community catalog.",
- ),
+ await screen.findByText("Removed Ada from the Marketplace."),
).toBeInTheDocument();
- expect(toggle).not.toBeChecked();
+ await vi.waitFor(() => expect(onClose).toHaveBeenCalledTimes(2));
+ expect(toggle()).not.toBeChecked();
});
-it("says why sharing failed and keeps the catalog as it was", async () => {
+it("closes on Done without publishing when the switch is unchanged", async () => {
const user = userEvent.setup();
const { session, outbox } = community();
+ const onClose = vi.fn();
render(
{}}
+ onClose={onClose}
/>,
+ { wrapper: ToastProvider },
);
- const toggle = screen.getByRole("switch", { name: "Share to catalog" });
- await vi.waitFor(() => expect(toggle).not.toHaveAttribute("aria-disabled"));
- await user.click(toggle);
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "Failed to update catalog sharing. Portable configuration appears to contain a credential. Remove it before sharing or exporting.",
- );
+ // Switched on and back off is no change.
+ await user.click(await listing());
+ await user.click(toggle());
+ await done(user);
+ expect(onClose).toHaveBeenCalledTimes(1);
expect(outbox.send).not.toHaveBeenCalled();
- expect(toggle).not.toBeChecked();
});
-it("asks the relay what an unanswered publish did before saying it failed", async () => {
+it("opens a published agent in the Marketplace from its toast", async () => {
const user = userEvent.setup();
- const { session, outbox, lose } = community();
+ const { session, accept } = community();
+ const onViewListing = vi.fn();
render(
{}}
/>,
+ { wrapper: ToastProvider },
);
- const toggle = screen.getByRole("switch", { name: "Share to catalog" });
- await vi.waitFor(() => expect(toggle).not.toHaveAttribute("aria-disabled"));
- await user.click(toggle);
+ await user.click(await listing());
+ await done(user);
+ accept();
+ await screen.findByText("Published Ada to the Marketplace.");
+ // A toast captures the pointer, which jsdom lacks; click it directly.
+ fireEvent.click(screen.getByRole("button", { name: "View" }));
+ expect(onViewListing).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({
+ id: `${owner}:${agent.pubkey}`,
+ name: "Ada",
+ instructions: "Review pull requests.",
+ }),
+ );
+});
+
+it("says up front why an agent can't be shared, before anything is sent", async () => {
+ const { session, outbox, read } = community();
+ share(session, {
+ config: { instructions: "password: hunter2hunter2", model: "" },
+ });
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Ada can’t be shared. Portable configuration appears to contain a credential. Remove it before sharing or exporting.",
+ );
+ await vi.waitFor(() => expect(read).toHaveBeenCalled());
+ await screen.findByText(LISTING_NOTE);
+ expect(toggle()).toHaveAttribute("aria-disabled", "true");
+ expect(exportButton()).toBeDisabled();
+ expect(await choice("Bea")).toBeDisabled();
+ expect(outbox.send).not.toHaveBeenCalled();
+});
+
+it("asks the relay what an unanswered publish did before saying it failed", async () => {
+ const user = userEvent.setup();
+ const { session, outbox, lose } = community();
+ share(session);
+ await user.click(await listing());
+ await done(user);
await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(1));
lose(true);
expect(
- await screen.findByText("Published Ada to the community catalog."),
+ await screen.findByText("Published Ada to the Marketplace."),
).toBeInTheDocument();
- expect(toggle).toBeChecked();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
- await user.click(toggle);
+ await user.click(await listing());
+ await done(user);
await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(2));
lose(false);
expect(await screen.findByRole("alert")).toHaveTextContent(
- "Failed to update catalog sharing. Try again.",
+ "Couldn’t remove Ada. Try again.",
);
- expect(toggle).toBeChecked();
+ // The switch keeps what was asked for, so Done tries again.
+ expect(await listing()).not.toBeChecked();
+ await done(user);
+ await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(3));
});
-it("says unsharing failed when a newer shared head wins over the accepted one", async () => {
- const user = userEvent.setup();
- const { session, outbox, accept, heads } = community();
- render(
- {}}
- />,
- );
- const toggle = screen.getByRole("switch", { name: "Share to catalog" });
- await vi.waitFor(() => expect(toggle).not.toHaveAttribute("aria-disabled"));
- await user.click(toggle);
+/** Publishes Ada, then switches the listing off. */
+async function publishThenRemove(
+ user: ReturnType,
+ { outbox, accept }: ReturnType,
+) {
+ await user.click(await listing());
+ await done(user);
await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(1));
accept();
- await vi.waitFor(() => expect(toggle).toBeChecked());
-
- await user.click(toggle);
+ await screen.findByText("Published Ada to the Marketplace.");
+ await user.click(await listing());
+ await done(user);
await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(2));
- // Another device shared it again, later, before this unshare landed.
- const shared = heads[0];
- if (shared) heads.push({ ...shared, id: "9".repeat(64), created_at: 99 });
- accept();
+}
+
+it("says removing failed when a newer shared head wins over the accepted one", async () => {
+ const user = userEvent.setup();
+ const fixture = community();
+ share(fixture.session);
+ await publishThenRemove(user, fixture);
+ // Another device shared it again, later, before this removal landed.
+ const shared = fixture.heads[0];
+ if (shared)
+ fixture.heads.push({ ...shared, id: "9".repeat(64), created_at: 99 });
+ fixture.accept();
expect(await screen.findByRole("alert")).toHaveTextContent(
- "Failed to update catalog sharing. Try again.",
+ "Couldn’t remove Ada. Try again.",
);
- expect(toggle).toBeChecked();
- expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ expect(await listing()).not.toBeChecked();
});
it("keeps the confirmed state when the relay cannot say which head won", async () => {
const user = userEvent.setup();
- const { session, outbox, accept, read } = community();
- render(
- {}}
- />,
- );
- const toggle = screen.getByRole("switch", { name: "Share to catalog" });
- await vi.waitFor(() => expect(toggle).not.toHaveAttribute("aria-disabled"));
- await user.click(toggle);
- await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(1));
- accept();
- await vi.waitFor(() => expect(toggle).toBeChecked());
-
- await user.click(toggle);
- await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(2));
- read.mockRejectedValueOnce(new Error("offline"));
- accept();
+ const fixture = community();
+ share(fixture.session);
+ await publishThenRemove(user, fixture);
+ fixture.read.mockRejectedValueOnce(new Error("offline"));
+ fixture.accept();
expect(await screen.findByRole("alert")).toHaveTextContent(
- "Failed to update catalog sharing. Try again.",
+ "Couldn’t remove Ada. Try again.",
);
- expect(toggle).toBeChecked();
- expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ expect(await listing()).not.toBeChecked();
});
-it("reads the catalog again after the first read fails", async () => {
+it("reads the listing again after the first read fails, leaving Export usable", async () => {
const user = userEvent.setup();
const { session, read } = community();
read.mockRejectedValueOnce(new Error("offline"));
- render(
- {}}
- />,
- );
- const toggle = screen.getByRole("switch", { name: "Share to catalog" });
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "Could not read the community catalog. Check the community connection, then retry.",
- );
- expect(toggle).toHaveAttribute("aria-disabled", "true");
+ share(session);
+ expect(
+ await screen.findByText("Couldn’t check the Marketplace."),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("switch", { name: /^Publish(ed)? to Marketplace$/ }),
+ ).toHaveAttribute("aria-disabled", "true");
+ expect(exportButton()).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Try again" }));
- await vi.waitFor(() => expect(toggle).not.toHaveAttribute("aria-disabled"));
- expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ await listing();
+ expect(
+ screen.queryByText("Couldn’t check the Marketplace."),
+ ).not.toBeInTheDocument();
});
it("exports the agent as an .agent.json snapshot", async () => {
@@ -327,18 +505,23 @@ it("exports the agent as an .agent.json snapshot", async () => {
expect(this.download).toBe("Ada.agent.json");
expect(this.href).toBe("blob:snapshot");
});
- render(
- {}}
- />,
+ share(community().session);
+ await user.click(
+ screen.getByRole("button", { name: "Choose an export format" }),
+ );
+ // The menu says what an agent card is before offering the backup format.
+ expect(
+ await screen.findByText(
+ "An agent card is an image that holds the agent’s setup, so anyone can add it to Buzz.",
+ ),
+ ).toBeInTheDocument();
+ await user.click(
+ await screen.findByRole("menuitemradio", { name: "JSON file" }),
);
- await user.click(screen.getByRole("combobox", { name: "File format" }));
- await user.click(await screen.findByRole("option", { name: "JSON" }));
- await user.click(screen.getByRole("button", { name: "Export agent" }));
- expect(await screen.findByRole("status")).toHaveTextContent("Exported Ada.");
+ await user.click(
+ screen.getByRole("button", { name: "Export Ada as a JSON file" }),
+ );
+ expect(await screen.findByText("Saved Ada.")).toBeInTheDocument();
expect(click).toHaveBeenCalledTimes(1);
expect(JSON.parse((await blobs[0]?.text()) ?? "")).toEqual({
format: "buzz-agent-snapshot",
@@ -367,16 +550,11 @@ it("names a desktop export so every platform accepts it", async () => {
["x".repeat(128), `${"x".repeat(64)}.agent.png`],
];
for (const [name, file] of names) {
- render(
- {}}
- />,
- );
- await user.click(screen.getByRole("button", { name: "Export agent" }));
- expect(await screen.findByRole("status")).toHaveTextContent("Exported");
+ share(community().session, { name });
+ await user.click(exportButton(name));
+ expect(
+ await screen.findByText(`Saved an agent card for ${name}.`),
+ ).toBeInTheDocument();
expect(desktop.invoke.mock.lastCall?.[2]).toEqual({
headers: { "x-buzz-file-name": file },
});
@@ -397,24 +575,15 @@ it("exports without the picture when it does not load in time", async () => {
},
);
try {
- render(
- {}}
- />,
- );
+ share(community().session, { picture: "https://media.example/ada.png" });
// Only the wait for the picture runs on a controlled clock.
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
- fireEvent.click(screen.getByRole("button", { name: "Export agent" }));
+ fireEvent.click(exportButton());
await vi.advanceTimersByTimeAsync(4999);
expect(desktop.invoke).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
vi.useRealTimers();
- expect(await screen.findByRole("status")).toHaveTextContent(
- "Exported Ada.",
- );
+ await vi.waitFor(() => expect(desktop.invoke).toHaveBeenCalledOnce());
expect(
await readSnapshot(new Blob([desktop.invoke.mock.calls[0]?.[1]])),
).toMatchObject({ name: "Ada", picture: "https://media.example/ada.png" });
@@ -424,28 +593,42 @@ it("exports without the picture when it does not load in time", async () => {
}
});
+it("draws pack art from the app's own copy, since its CDN can't be read back", async () => {
+ const pack = agentAvatars[0];
+ if (!pack?.preview) throw new Error("Bundled avatar catalog is empty");
+ const sources: string[] = [];
+ vi.stubGlobal(
+ "Image",
+ class {
+ crossOrigin = "";
+ set src(value: string) {
+ if (value) sources.push(value);
+ }
+ decode = () => Promise.reject(new Error("not drawn in jsdom"));
+ },
+ );
+ share(community().session, { picture: `${pack.url}#buzz-anim=x` });
+ fireEvent.click(exportButton());
+ expect(
+ await screen.findByText("Saved an agent card for Ada."),
+ ).toBeInTheDocument();
+ expect(sources).toEqual([pack.preview]);
+});
+
it("saves an .agent.png natively on desktop, never through a blob link", async () => {
const user = userEvent.setup();
desktop.tauri = true;
desktop.invoke.mockResolvedValueOnce("/Users/ada/Downloads/Ada.agent.png");
const click = vi.spyOn(HTMLAnchorElement.prototype, "click");
try {
- render(
- {}}
- />,
- );
- expect(
- screen.getByRole("combobox", { name: "File format" }),
- ).toHaveTextContent("PNG");
- await user.click(screen.getByRole("button", { name: "Export agent" }));
+ share(community().session);
+ await user.click(exportButton());
// Desktop says where the file went.
- expect(await screen.findByRole("status")).toHaveTextContent(
- "Exported Ada to /Users/ada/Downloads/Ada.agent.png.",
- );
+ expect(
+ await screen.findByText(
+ "Saved an agent card for Ada to /Users/ada/Downloads/Ada.agent.png.",
+ ),
+ ).toBeInTheDocument();
expect(click).not.toHaveBeenCalled();
expect(desktop.invoke).toHaveBeenCalledOnce();
const [command, bytes, options] = desktop.invoke.mock.calls[0] ?? [];
@@ -465,7 +648,7 @@ it("saves an .agent.png natively on desktop, never through a blob link", async (
}
});
-it("updates a share older than the agent, keeping it listed", async () => {
+it("updates a listing older than the agent, keeping it listed", async () => {
const user = userEvent.setup();
const { session, heads, outbox, accept } = community();
heads.push({
@@ -479,34 +662,20 @@ it("updates a share older than the agent, keeping it listed", async () => {
],
content: JSON.stringify({ display_name: "Ada", system_prompt: "Old." }),
});
- render(
- {}}
- />,
- );
- const dialog = screen.getByRole("dialog", { name: "Share Ada" });
+ share(session);
expect(
- await within(dialog).findByText(
- "The shared copy is older than this agent.",
+ await screen.findByText(
+ "Your latest changes aren’t in the Marketplace yet.",
),
).toBeInTheDocument();
await user.click(
- within(dialog).getByRole("button", { name: "Update share" }),
+ await screen.findByRole("button", { name: "Update Marketplace" }),
);
await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(1));
expect(outbox.send).toHaveBeenLastCalledWith(
{
kind: 30175,
- content: JSON.stringify({
- display_name: "Ada",
- system_prompt: "Review pull requests.",
- acp_command: "buzz-acp",
- runtime: "echo",
- model: "echo-1",
- }),
+ content: sharedContent,
tags: [
["d", agent.pubkey],
["shared", "true"],
@@ -518,12 +687,269 @@ it("updates a share older than the agent, keeping it listed", async () => {
);
accept();
expect(
- await within(dialog).findByText("Published Ada to the community catalog."),
+ await screen.findByText("Published Ada to the Marketplace."),
).toBeInTheDocument();
expect(
- within(dialog).queryByRole("button", { name: "Update share" }),
+ screen.queryByRole("button", { name: "Update Marketplace" }),
).not.toBeInTheDocument();
expect(
- within(dialog).getByRole("switch", { name: "Share to catalog" }),
+ screen.getByRole("switch", { name: /^Publish(ed)? to Marketplace$/ }),
).toBeChecked();
});
+
+it("says an update failed when the listed head is not what it sent", async () => {
+ const user = userEvent.setup();
+ const { session, heads, outbox, lose } = community();
+ heads.push({
+ id: "9".repeat(64),
+ pubkey: owner,
+ created_at: 0,
+ kind: 30175,
+ tags: [
+ ["d", agent.pubkey],
+ ["shared", "true"],
+ ],
+ content: JSON.stringify({ display_name: "Ada", system_prompt: "Old." }),
+ });
+ share(session);
+ await user.click(
+ await screen.findByRole("button", { name: "Update Marketplace" }),
+ );
+ await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(1));
+ // It never landed, and the old listing is still shared.
+ lose(false);
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Couldn’t publish Ada. Try again.",
+ );
+ expect(
+ await screen.findByText(
+ "Your latest changes aren’t in the Marketplace yet.",
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Update Marketplace" }),
+ ).toBeEnabled();
+ expect(toggle()).toBeChecked();
+});
+
+it("sends each person a message with one uploaded copy of the agent", async () => {
+ const user = userEvent.setup();
+ const { session, directMessages, attachments, messages, outbox, journal } =
+ community();
+ share(session);
+ // With nobody chosen, the window just closes.
+ expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
+ await user.click(await choice("Bea"));
+ await user.click(await choice("Cy"));
+ expect(
+ screen.queryByRole("button", { name: "Done" }),
+ ).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(
+ await screen.findByText("Sent Ada to Bea and Cy."),
+ ).toBeInTheDocument();
+ expect(directMessages.open.mock.calls.map(([people]) => people)).toEqual([
+ [bea.pubkey],
+ [cy.pubkey],
+ ]);
+ // Community media is readable from any conversation, so one upload serves.
+ expect(attachments.upload).toHaveBeenCalledOnce();
+ const [file, channel] = attachments.upload.mock.calls[0] ?? [];
+ expect(channel).toBe("dm-b");
+ expect(file?.name).toBe("Ada.agent.png");
+ expect(await readSnapshot(file as File)).toMatchObject({
+ name: "Ada",
+ instructions: "Review pull requests.",
+ });
+ expect(messages.send.mock.calls.map(([to]) => to)).toEqual(["dm-b", "dm-c"]);
+ expect(messages.send.mock.calls[0]?.[1]).toContain("Choose the card");
+ expect((messages.send.mock.calls[0] as unknown[] | undefined)?.[3]).toEqual([
+ expect.objectContaining({ name: "Ada.agent.png" }),
+ ]);
+ expect(directMessages.delivered).toHaveBeenCalledWith(
+ "sent-dm-c",
+ "dm-c",
+ expect.any(AbortSignal),
+ );
+ // Delivered, each message leaves the outbox.
+ expect(outbox.acknowledge.mock.calls.map(([id]) => id)).toEqual([
+ "sent-dm-b",
+ "sent-dm-c",
+ ]);
+ expect(journal).toEqual([]);
+ // Everyone was reached, so the window is ready to close.
+ expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
+ expect(
+ screen.queryByRole("region", { name: /^Sending to/ }),
+ ).not.toBeInTheDocument();
+});
+
+it("keeps who it couldn't reach, then confirms their message instead of resending it", async () => {
+ const user = userEvent.setup();
+ const { session, directMessages, messages } = community();
+ directMessages.delivered.mockImplementation(async (id) => {
+ if (id === "sent-dm-c") throw new Error("Not delivered");
+ });
+ share(session);
+ await user.click(await choice("Bea"));
+ await user.click(await choice("Cy"));
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Couldn’t send to Cy. Not delivered.",
+ );
+ expect(screen.getByText("Sent Ada to Bea.")).toBeInTheDocument();
+ const chosen = screen.getByRole("region", { name: "Sending to · 1" });
+ expect(
+ within(chosen).getByRole("button", { name: "Cy", pressed: true }),
+ ).toBeInTheDocument();
+ // The relay may still have it, so trying again waits on the same message.
+ directMessages.delivered.mockResolvedValue(undefined);
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(await screen.findByText("Sent Ada to Cy.")).toBeInTheDocument();
+ expect(messages.send).toHaveBeenCalledTimes(2);
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+});
+
+it("sends to the rest when one upload fails, and reuses the next upload", async () => {
+ const user = userEvent.setup();
+ const { session, attachments, messages } = community();
+ attachments.upload.mockRejectedValueOnce(new Error("Upload refused"));
+ share(session);
+ await user.click(await choice("Bea"));
+ await user.click(await choice("Cy"));
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Couldn’t send to Bea. Upload refused.",
+ );
+ expect(screen.getByText("Sent Ada to Cy.")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(await screen.findByText("Sent Ada to Bea.")).toBeInTheDocument();
+ expect(attachments.upload).toHaveBeenCalledTimes(2);
+ expect(messages.send.mock.calls.map(([to]) => to)).toEqual(["dm-c", "dm-b"]);
+ // With everyone reached, focus lands on Done.
+ await vi.waitFor(() =>
+ expect(screen.getByRole("button", { name: "Done" })).toHaveFocus(),
+ );
+});
+
+it("confirms an unconfirmed message after a reload rather than sending it again", async () => {
+ const user = userEvent.setup();
+ const { session, directMessages, messages, attachments, outbox } =
+ community();
+ directMessages.delivered.mockRejectedValueOnce(new Error("Offline"));
+ share(session);
+ await user.click(await choice("Bea"));
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Couldn’t send to Bea. Offline.",
+ );
+ cleanup();
+ // A reload keeps only what the outbox saved, under a new session.
+ share({ ...session, scope: "reloaded" } as RelaySession);
+ await user.click(await choice("Bea"));
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(await screen.findByText("Sent Ada to Bea.")).toBeInTheDocument();
+ expect(messages.send).toHaveBeenCalledOnce();
+ expect(attachments.upload).toHaveBeenCalledOnce();
+ expect(directMessages.delivered).toHaveBeenLastCalledWith(
+ "sent-dm-b",
+ "dm-b",
+ expect.any(AbortSignal),
+ );
+ expect(outbox.acknowledge).toHaveBeenCalledWith("sent-dm-b");
+});
+
+it("sends anew once a failed message is removed from the outbox", async () => {
+ const user = userEvent.setup();
+ const { session, directMessages, messages, outbox, journal } = community();
+ directMessages.delivered.mockRejectedValueOnce(new Error("Refused"));
+ share(session);
+ await user.click(await choice("Bea"));
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Couldn’t send to Bea. Refused.",
+ );
+ // Undecided, it stays; refused for good, it can be removed.
+ await expect(outbox.dismiss("sent-dm-b")).rejects.toThrow();
+ const [sent] = journal;
+ assert.exists(sent);
+ sent.delivery = "failed";
+ await outbox.dismiss("sent-dm-b");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(await screen.findByText("Sent Ada to Bea.")).toBeInTheDocument();
+ expect(messages.send).toHaveBeenCalledTimes(2);
+ expect(directMessages.delivered).toHaveBeenLastCalledWith(
+ "sent-dm-b",
+ "dm-b",
+ expect.any(AbortSignal),
+ );
+});
+
+it("keeps a delivered message for confirming when the outbox can't let it go", async () => {
+ const user = userEvent.setup();
+ const { session, messages, outbox } = community();
+ outbox.acknowledge.mockRejectedValueOnce(new Error("Storage is full"));
+ share(session);
+ await user.click(await choice("Bea"));
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Couldn’t send to Bea. Storage is full.",
+ );
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(await screen.findByText("Sent Ada to Bea.")).toBeInTheDocument();
+ expect(messages.send).toHaveBeenCalledOnce();
+});
+
+it("sends an edited agent anew rather than confirming an earlier copy", async () => {
+ const user = userEvent.setup();
+ const { session, directMessages, messages, attachments } = community();
+ directMessages.delivered.mockRejectedValueOnce(new Error("Offline"));
+ const first = share(session);
+ await user.click(await choice("Bea"));
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Couldn’t send to Bea. Offline.",
+ );
+ first.unmount();
+ share(session, {
+ config: { instructions: "Review small pull requests.", model: "echo-1" },
+ });
+ await user.click(await choice("Bea"));
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(await screen.findByText("Sent Ada to Bea.")).toBeInTheDocument();
+ expect(messages.send).toHaveBeenCalledTimes(2);
+ expect(attachments.upload).toHaveBeenCalledTimes(2);
+ // The edited copy is what was uploaded the second time.
+ const card = attachments.upload.mock.calls[1]?.[0] as File;
+ expect((await readSnapshot(card)).instructions).toBe(
+ "Review small pull requests.",
+ );
+});
+
+it("posts to a channel the viewer belongs to, and never offers agents", async () => {
+ const user = userEvent.setup();
+ const { session, directMessages, attachments, messages } = community();
+ share(session);
+ // Search opens ready to type, and narrows people and channels together.
+ const field = screen.getByRole("searchbox", {
+ name: "Search people and channels",
+ });
+ await vi.waitFor(() => expect(field).toHaveFocus());
+ await choice("Bea");
+ expect(
+ screen.queryByRole("button", { name: "Echo" }),
+ ).not.toBeInTheDocument();
+ await user.type(field, "#des");
+ await user.click(await choice("#design"));
+ await vi.waitFor(() =>
+ expect(
+ screen.queryByRole("button", { name: "Bea" }),
+ ).not.toBeInTheDocument(),
+ );
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(await screen.findByText("Sent Ada to #design.")).toBeInTheDocument();
+ // A channel gets one message in it, not a direct message to each member.
+ expect(directMessages.open).not.toHaveBeenCalled();
+ expect(attachments.upload.mock.calls[0]?.[1]).toBe(design.id);
+ expect(messages.send.mock.calls.map(([to]) => to)).toEqual([design.id]);
+});
diff --git a/src/bundled/agents/AgentShareDialog.tsx b/src/bundled/agents/AgentShareDialog.tsx
index 50ed11883a..bc2676d605 100644
--- a/src/bundled/agents/AgentShareDialog.tsx
+++ b/src/bundled/agents/AgentShareDialog.tsx
@@ -1,4 +1,5 @@
-// Share: the agent's catalog listing in its community, and a file export.
+// Share: send the agent to people or a channel, list it in its community's Marketplace, or
+// export it as a file.
import { isTauri } from "@tauri-apps/api/core";
import { useEffect, useRef, useState } from "react";
import {
@@ -11,22 +12,41 @@ import {
snapshotPng,
} from "../../features/agents/agent-share";
import type { Agent, RegisteredAgentType } from "../../features/agents/service";
+import {
+ type SharedAgent,
+ sharedAgents,
+} from "../../features/agents/shared-agents";
import type { EventData } from "../../features/relay/events";
import { downloadNativeFile } from "../../features/messages/native-download";
+import type { UploadedAttachment } from "../../features/relay/attachments";
import type { RelaySession } from "../../features/relay/session";
+import { bundledPoster } from "../../features/agents/avatar-packs";
import { avatarSource } from "../../shared/avatar-source";
import { Button } from "../../shared/design-system/ui/Button";
import { Dialog } from "../../shared/design-system/ui/Dialog";
-import { Select } from "../../shared/design-system/ui/Select";
+import {
+ MenuNote,
+ MenuRadioGroup,
+ MenuRadioItem,
+} from "../../shared/design-system/ui/Menu";
import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup";
+import { SplitButton } from "../../shared/design-system/ui/SplitButton";
import { SwitchPreferenceRow } from "../../shared/design-system/ui/SwitchPreferenceRow";
+import { useToastNotification } from "../../shared/design-system/ui/Toast";
+import styles from "./AgentShareDialog.module.css";
+import {
+ AgentShareRecipients,
+ type ShareTarget,
+ targetName,
+} from "./AgentShareRecipients";
const message = (error: unknown) =>
error instanceof Error ? error.message : String(error);
-const FORMATS = [
- { value: "png", label: "PNG" },
- { value: "json", label: "JSON" },
-];
+/** What went wrong in a sentence of its own. */
+const errorText = (error: unknown) => {
+ const text = message(error);
+ return /[.!?]$/.test(text) ? text : `${text}.`;
+};
/** How long export waits for the picture, which keeps the dialog open. */
const ARTWORK_MS = 5000;
@@ -35,9 +55,11 @@ const ARTWORK_MS = 5000;
* unreadable or slow artwork leaves the snapshot without it. */
async function artwork(picture: string | undefined, session: RelaySession) {
const source = avatarSource(picture);
+ // Pack art is read from the app's own copy; other pictures go through the
+ // session's media path.
const src = source?.startsWith("data:")
? source
- : source && session.media(source);
+ : source && (bundledPoster(source) ?? session.media(source));
if (!src) return undefined;
const image = new Image();
let timer: ReturnType | undefined;
@@ -78,7 +100,7 @@ function fileName(name: string, format: string) {
/** Desktop saves natively and says where; a browser downloads the file to
* wherever its settings say. */
-async function save(file: Blob, name: string): Promise {
+async function saveFile(file: Blob, name: string): Promise {
if (isTauri()) return downloadNativeFile(file, name);
const url = URL.createObjectURL(file);
const link = document.createElement("a");
@@ -89,23 +111,60 @@ async function save(file: Blob, name: string): Promise {
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+/** The outbox's durable name for one copy of an agent sent to one person or
+ * channel, so a retry, even after a reload, finds that message again rather
+ * than sending another. An edited agent is a different copy. */
+async function shareRecovery(agent: Agent, target: ShareTarget, copy: string) {
+ const digest = await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(copy),
+ );
+ const hex = Array.from(new Uint8Array(digest), (byte) =>
+ byte.toString(16).padStart(2, "0"),
+ ).join("");
+ return {
+ key: `agent-share:${agent.pubkey}:${target.id}:${hex.slice(0, 40)}`,
+ value: "1",
+ };
+}
+
+/** Names in a sentence: "Ana", "Ana and #design", "Ana, Ben and Cy". */
+const names = (targets: readonly ShareTarget[]) => {
+ const all = targets.map(targetName);
+ const last = all.pop() ?? "";
+ return all.length ? `${all.join(", ")} and ${last}` : last;
+};
+
export function AgentShareDialog({
agent,
type,
session,
+ onViewListing,
onClose,
}: {
agent: Agent;
type: RegisteredAgentType | undefined;
session: RelaySession;
+ /** Opens the agent as the Marketplace lists it. */
+ onViewListing?: ((agent: SharedAgent) => void) | undefined;
onClose(): void;
}) {
+ const notify = useToastNotification();
// `null` while the catalog is read; `undefined` once read with no head.
const [head, setHead] = useState(null);
- const [pending, setPending] = useState(false);
- const [notice, setNotice] = useState("");
+ const [pending, setPending] = useState<
+ "done" | "update" | "export" | "send"
+ >();
+ // What the switch asks for; Done publishes or removes it.
+ const [wanted, setWanted] = useState();
+ // The listing being published or removed, while the relay answers.
+ const [listing, setListing] = useState();
+ const [failure, setFailure] = useState("");
const [error, setError] = useState("");
const [format, setFormat] = useState<"png" | "json">("png");
+ const [targets, setTargets] = useState([]);
+ const [query, setQuery] = useState("");
+ const search = useRef(null);
// The read failed; clearing it reads again.
const [unread, setUnread] = useState(false);
const attempt = useRef(null);
@@ -119,21 +178,34 @@ export function AgentShareDialog({
);
return () => controller.abort();
}, [session, agent.owner, agent.pubkey, unread]);
- const share = async (shared: boolean) => {
+ // A share is a copy: edits reach the Marketplace only when it is updated.
+ let definition: ReturnType | undefined;
+ let refused = "";
+ try {
+ definition = shareable(agent, type);
+ } catch (reason) {
+ refused = message(reason);
+ }
+ const current = definition && catalogContent(definition);
+ const listed = isShared(head ?? undefined);
+ const stale = !!head && listed && current !== head.content;
+ /** Publishes or removes the listing; says whether the relay confirmed it. */
+ const share = async (shared: boolean, via: "done" | "update") => {
const signal = attempt.current?.signal;
- if (head === null || pending || !signal) return;
- setPending(true);
- setNotice("");
- setError("");
- let failure = "Try again.";
+ if (head === null || pending || !signal) return false;
+ setPending(via);
+ setListing(shared);
+ setFailure("");
+ let reason = "Try again.";
+ let content: string | undefined;
try {
- // Unsharing keeps what was shared, so it never depends on the agent
+ // Removing keeps what was shared, so it never depends on the agent
// still being shareable.
- const content =
+ content =
!shared && head ? head.content : catalogContent(shareable(agent, type));
await publishShare(session, agent.pubkey, content, shared, head, signal);
- } catch (reason) {
- failure = message(reason);
+ } catch (error) {
+ reason = message(error);
}
// An accepted publish can still lose to a newer head, and an unanswered
// one may still have landed, so the relay says which head won. Until it
@@ -144,115 +216,301 @@ export function AgentShareDialog({
agent.pubkey,
signal,
).catch(() => head);
- if (signal.aborted) return;
+ setPending(undefined);
+ setListing(undefined);
+ if (signal.aborted) return false;
setHead(next);
- if (isShared(next) === shared)
- setNotice(
+ // An update to a listed agent leaves it listed whatever happened, so a
+ // publish has landed only when the winning head is what it sent.
+ if (
+ shared ? isShared(next) && next?.content === content : !isShared(next)
+ ) {
+ const listed = shared ? sharedAgents(next ? [next] : [])[0] : undefined;
+ notify(
shared
- ? `Published ${agent.name} to the community catalog.`
- : `${agent.name} is no longer discoverable in the community catalog.`,
+ ? `Published ${agent.name} to the Marketplace.`
+ : `Removed ${agent.name} from the Marketplace.`,
+ "success",
+ listed && onViewListing
+ ? { label: "View", onClick: () => onViewListing(listed) }
+ : undefined,
);
- else setError(`Failed to update catalog sharing. ${failure}`);
- setPending(false);
+ return true;
+ }
+ // The switch keeps what was asked for, so Done tries again.
+ setFailure(
+ `Couldn’t ${shared ? "publish" : "remove"} ${agent.name}. ${reason}`,
+ );
+ return false;
};
- // A share is a copy: edits reach the catalog only when it is updated. What
- // can't be shared any more says why on Update share.
- let current: string | undefined;
- try {
- current = catalogContent(shareable(agent, type));
- } catch {}
- const stale = !!head && isShared(head) && current !== head.content;
- const exportAgent = async () => {
- if (pending) return;
- setPending(true);
- setNotice("");
+ const finish = async () => {
+ if (wanted === undefined || wanted === listed) return onClose();
+ if (await share(wanted, "done")) onClose();
+ };
+ const file = async (as: "png" | "json") =>
+ definition &&
+ (as === "png"
+ ? snapshotPng(definition, await artwork(agent.picture, session))
+ : new Blob([snapshotJson(definition)], { type: "application/json" }));
+ const save = async () => {
+ if (pending || !definition) return;
+ setPending("export");
setError("");
try {
- const definition = shareable(agent, type);
- const file =
- format === "png"
- ? snapshotPng(definition, await artwork(agent.picture, session))
- : new Blob([snapshotJson(definition)], { type: "application/json" });
- const path = await save(file, fileName(agent.name, format));
- setNotice(
- path ? `Exported ${agent.name} to ${path}.` : `Exported ${agent.name}.`,
+ const snapshot = await file(format);
+ if (!snapshot) return;
+ const path = await saveFile(snapshot, fileName(agent.name, format));
+ notify(
+ `${format === "png" ? "Saved an agent card for" : "Saved"} ${agent.name}${path ? ` to ${path}` : ""}.`,
+ "success",
);
} catch (reason) {
- setError(`Failed to export agent snapshot. ${message(reason)}`);
+ setError(`Couldn’t export ${agent.name}. ${message(reason)}`);
+ } finally {
+ setPending(undefined);
}
- setPending(false);
};
+ const primary = useRef(null);
+ const upload = useRef<{ content: string; file: UploadedAttachment }>(
+ undefined,
+ );
+ const sending = useRef(null);
+ useEffect(() => () => sending.current?.abort(), []);
+ const send = async () => {
+ if (pending || !definition || !targets.length) return;
+ const uploads = session.attachments;
+ const outbox = session.outbox;
+ if (!uploads || !outbox) {
+ setError("This community can’t send agents in messages.");
+ return;
+ }
+ const controller = new AbortController();
+ sending.current = controller;
+ const { signal } = controller;
+ setPending("send");
+ setError("");
+ const failed: ShareTarget[] = [];
+ const reasons = new Set();
+ for (const target of targets) {
+ try {
+ const recovery = await shareRecovery(agent, target, current ?? "");
+ await outbox.ready();
+ signal.throwIfAborted();
+ // A message the outbox still holds for this copy is confirmed, and
+ // retried there if need be, rather than sent again.
+ const earlier = outbox
+ .snapshot()
+ .find((item) => item.recovery?.key === recovery.key)?.event;
+ let message = earlier && {
+ id: earlier.id,
+ channel: earlier.tags.find(([name]) => name === "h")?.[1] ?? "",
+ };
+ if (!message) {
+ if (target.kind === "person" && !session.directMessages.available)
+ throw new Error("Direct messages aren’t available here.");
+ // A person gets it in a direct message; a channel, in the channel.
+ const channel =
+ target.kind === "channel"
+ ? target.id
+ : await session.directMessages.open([target.id], signal);
+ // One upload serves every message of this copy: community media
+ // isn't scoped to a conversation.
+ let copy = upload.current;
+ if (!copy || copy.content !== current)
+ upload.current = copy = {
+ content: current ?? "",
+ file: await uploads.upload(
+ new File(
+ [(await file("png")) as Blob],
+ fileName(agent.name, "png"),
+ { type: "image/png" },
+ ),
+ channel,
+ signal,
+ ),
+ };
+ message = {
+ channel,
+ id: session.messages.send(
+ channel,
+ `Here’s a copy of my agent, ${agent.name}. Choose the card to add it to your agents.`,
+ [],
+ [copy.file],
+ recovery,
+ ),
+ };
+ }
+ await session.directMessages.delivered(
+ message.id,
+ message.channel,
+ signal,
+ );
+ // Delivered: the outbox may let the message go.
+ await outbox.acknowledge(message.id);
+ } catch (error) {
+ if (signal.aborted) return;
+ failed.push(target);
+ reasons.add(errorText(error));
+ }
+ }
+ if (signal.aborted) return;
+ setPending(undefined);
+ const delivered = targets.filter((target) => !failed.includes(target));
+ if (delivered.length)
+ notify(`Sent ${agent.name} to ${names(delivered)}.`, "success");
+ // What it couldn't reach stays chosen, to try again.
+ setTargets(failed);
+ if (failed.length)
+ setError(
+ `Couldn’t send to ${names(failed)}. ${reasons.size === 1 ? [...reasons][0] : "Try again."}`,
+ );
+ // Send gives way to Done once everyone is reached; focus follows it.
+ requestAnimationFrame(() => primary.current?.focus());
+ };
+ const busy = !!pending;
return (
{
if (!open) onClose();
}}
+ leadingActions={
+ void save()}
+ menu={
+ <>
+
+ An agent card is an image that holds the agent’s setup, so
+ anyone can add it to Buzz.
+
+
+ setFormat(value as "png" | "json")
+ }
+ >
+
+ Agent card
+
+
+ JSON file
+
+
+ >
+ }
+ >
+ {format === "png" ? "Export agent card" : "Export JSON"}
+
+ }
actions={
- <>
-
- Close
+ targets.length ? (
+ void send()}
+ >
+ Send
- void exportAgent()}>
- Export agent
+ ) : (
+ void finish()}
+ >
+ Done
- >
+ )
}
>
-
-
- void share(shared)}
- />
- setFormat(value as "png" | "json")}
- />
-
- {stale && (
-
-
- The shared copy is older than this agent.
-
-
void share(true)}>
- Update share
-
-
- )}
- {notice && (
-
- {notice}
+
+ {refused && (
+
+ {agent.name} can’t be shared. {refused}
)}
+
{
+ setTargets(next);
+ setError("");
+ }}
+ />
{error && (
{error}
)}
- {unread && (
-
-
- Could not read the community catalog. Check the community
- connection, then retry.
+
+
+ {
+ setWanted(on);
+ setFailure("");
+ }}
+ />
+
+ {failure && (
+
+ {failure}
-
setUnread(false)}>Try again
-
- )}
+ )}
+ {unread && (
+
setUnread(false)}>
+ Try again
+
+ )}
+ {stale && (wanted ?? listed) && (
+
void share(true, "update")}
+ >
+ Update Marketplace
+
+ )}
+
);
diff --git a/src/bundled/agents/AgentShareRecipients.tsx b/src/bundled/agents/AgentShareRecipients.tsx
new file mode 100644
index 0000000000..f4b704a3ae
--- /dev/null
+++ b/src/bundled/agents/AgentShareRecipients.tsx
@@ -0,0 +1,157 @@
+// Who Share sends an agent to: people by direct message, or a channel the
+// viewer belongs to, found with one search as Share access does.
+import { useSyncExternalStore, type Ref } from "react";
+import { usePeople } from "../../features/direct-messages/usePeople";
+import type { RelaySession } from "../../features/relay/session";
+import { avatarMedia } from "../../shared/avatar-source";
+import { CheckIcon, HashIcon } from "../../shared/design-system/icons";
+import { Avatar } from "../../shared/design-system/ui/Avatar";
+import { NavigationItem } from "../../shared/design-system/ui/NavigationItem";
+import { SearchField } from "../../shared/design-system/ui/SearchField";
+import styles from "./AgentShareDialog.module.css";
+
+export type ShareTarget =
+ | { kind: "person"; id: string; name: string; picture?: string | undefined }
+ | { kind: "channel"; id: string; name: string };
+
+/** A target as a sentence names it: "Bea" or "#design". */
+export const targetName = (target: ShareTarget) =>
+ target.kind === "channel" ? `#${target.name}` : target.name;
+
+// The directory is unbounded; browsing offers a few, searching narrows it.
+const BROWSE_PEOPLE = 8;
+
+export function AgentShareRecipients({
+ session,
+ query,
+ chosen,
+ disabled,
+ inputRef,
+ onQueryChange,
+ onChange,
+}: {
+ session: RelaySession;
+ query: string;
+ chosen: readonly ShareTarget[];
+ disabled: boolean;
+ inputRef: Ref
;
+ onQueryChange(query: string): void;
+ onChange(targets: ShareTarget[]): void;
+}) {
+ const directory = usePeople(session, query);
+ const list = useSyncExternalStore(
+ session.channels.subscribeList,
+ session.channels.list,
+ );
+ const picked = new Set(chosen.map((target) => target.id));
+ const needle = query.trim().toLowerCase().replace(/^#/, "");
+ const people: ShareTarget[] = directory.people
+ .filter(
+ (person) =>
+ !person.isAgent &&
+ person.pubkey !== session.viewer &&
+ !picked.has(person.pubkey),
+ )
+ .slice(0, needle ? undefined : BROWSE_PEOPLE)
+ .map((person) => ({
+ kind: "person",
+ id: person.pubkey,
+ name: person.name,
+ picture: person.picture,
+ }));
+ // Channels the viewer can post in, as Share access offers them.
+ const channels: ShareTarget[] = list.channels
+ .filter(
+ (item) =>
+ !item.huddle &&
+ !item.metadataPending &&
+ !item.readOnly &&
+ !item.cached &&
+ !item.archived &&
+ ["stream", "forum"].includes(item.channelType ?? "") &&
+ item.members?.includes(session.viewer ?? "") &&
+ !picked.has(item.id) &&
+ item.name.toLowerCase().includes(needle),
+ )
+ .map((item) => ({ kind: "channel", id: item.id, name: item.name }));
+ const row = (target: ShareTarget, isChosen: boolean) => (
+
+
+ onChange(
+ isChosen
+ ? chosen.filter((item) => item.id !== target.id)
+ : [...chosen, target],
+ )
+ }
+ icon={
+ target.kind === "channel" ? (
+
+ ) : (
+
+ )
+ }
+ label={targetName(target)}
+ trailing={isChosen && }
+ />
+
+ );
+ const group = (title: string, targets: ShareTarget[], isChosen = false) =>
+ targets.length > 0 && (
+
+
+ {title}
+
+ {targets.map((target) => row(target, isChosen))}
+
+ );
+ return (
+ <>
+
+
+ {group(`Sending to · ${chosen.length}`, [...chosen], true)}
+ {group("People", people)}
+ {group("Channels", channels)}
+ {directory.loading && !people.length ? (
+
+ {needle ? "Searching…" : "Loading people…"}
+
+ ) : (
+ !people.length &&
+ !channels.length && (
+
+ {needle
+ ? "No people or channels match your search."
+ : "No one else to send to yet."}
+
+ )
+ )}
+ {directory.error && (
+
+ {directory.error}
+
+ )}
+
+ >
+ );
+}
diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx
index d183ce6835..105dd02f9b 100644
--- a/src/bundled/agents/AgentsPage.test.tsx
+++ b/src/bundled/agents/AgentsPage.test.tsx
@@ -697,6 +697,36 @@ it("restores legacy provider links and writes close visits", async () => {
);
});
+it("opens the agent a link names, on Your agents", async () => {
+ const target = {
+ version: 1,
+ kind: "page",
+ pluginId: "buzz.agents",
+ pageId: "agents",
+ scope: null,
+ route: { version: 1, params: { agent: "2".repeat(64) } },
+ } as const;
+ const navigator = {
+ snapshot: () => ({ entry: { id: "agent", target } }),
+ open: vi.fn(async () => ({ status: "opened" as const })),
+ } as unknown as Navigation;
+ const navigation: PageNavigation = {
+ entryId: "agent",
+ target,
+ signal: new AbortController().signal,
+ focus: "content",
+ forSession: () => navigation,
+ resolve: () => true,
+ complete: () => true,
+ };
+ setup([agent("Ada", 1), agent("Bea", 2)], {
+ page: { navigator, navigation },
+ });
+ const profile = await screen.findByRole("region", { name: "Agent profile" });
+ expect(within(profile).getAllByText("Bea").length).toBeGreaterThan(0);
+ expect(within(profile).queryByText("Ada")).toBeNull();
+});
+
it("shows an agent's picture in the grid and side panel", async () => {
const user = userEvent.setup();
const picture = "https://old.example/media/a.png";
@@ -1430,12 +1460,12 @@ it("imports an exported agent file as a new agent of the type that runs it", asy
),
);
const confirmation = await screen.findByRole("dialog", {
- name: "Import Scout",
+ name: "Scout",
});
// What the new agent will follow is shown before it is accepted.
expect(
- within(confirmation).getByRole("region", { name: "Agent instructions" }),
- ).toHaveTextContent("Scout ahead.");
+ within(confirmation).getByRole("textbox", { name: "Instructions" }),
+ ).toHaveValue("Scout ahead.");
await user.click(
within(confirmation).getByRole("button", { name: "Import" }),
);
@@ -3349,7 +3379,7 @@ it("opens a Marketplace tile in the profile pane and returns focus on close and
await user.click(tile);
await user.click(screen.getByRole("button", { name: "Import" }));
const dialog = screen.getByRole("dialog", {
- name: "Import Marketplace helper",
+ name: "Marketplace helper",
});
await user.click(within(dialog).getByRole("button", { name: "Import" }));
await vi.waitFor(() =>
diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx
index f1a3337203..e94d9cef4d 100644
--- a/src/bundled/agents/AgentsPage.tsx
+++ b/src/bundled/agents/AgentsPage.tsx
@@ -32,6 +32,7 @@ import {
SharedImportDialog,
} from "./AgentBrowse";
import { AgentShareDialog } from "./AgentShareDialog";
+import type { SharedAgent } from "../../features/agents/shared-agents";
import { AgentCard, AgentPortrait, TeamCard } from "./AgentCard";
import { AgentCreateDialog, TypeIcon } from "./AgentCreateDialog";
import { NewAgentTile } from "./NewAgentTile";
@@ -109,7 +110,7 @@ import { Textarea } from "../../shared/design-system/ui/Textarea";
import { Tooltip } from "../../shared/design-system/ui/Tooltip";
import { publicKeyLabels } from "../../shared/identity/public-key";
import { readView, writeView } from "../../shared/view-state";
-import { agentProviderRoute, agentProviderTarget } from "./routes";
+import { agentProviderRoute, agentProviderTarget, agentRoute } from "./routes";
const message = (error: unknown) =>
error instanceof Error ? error.message : String(error);
@@ -275,6 +276,19 @@ export function AgentsPage({
if (next !== "marketplace") setSharedSelection(undefined);
setTab(next);
};
+ // Opened for one of your agents, such as from a card sent in chat.
+ const routedAgent =
+ routed && navigation.target.kind === "page"
+ ? agentRoute(navigation.target.route?.params)
+ : undefined;
+ const routedEntry = navigation?.entryId;
+ useEffect(() => {
+ if (!routedAgent || !routedEntry) return;
+ setEditingTeam(undefined);
+ setSharedSelection(undefined);
+ setTab("yours");
+ setSelectedKey(routedAgent);
+ }, [routedAgent, routedEntry]);
const returnTo = (next: Tab) => () => {
setFlow(undefined);
changeTab(next);
@@ -425,6 +439,17 @@ export function AgentsPage({
onSelect: browse,
onBuild: build,
newAgent: !toolbarAction && starts,
+ // A published agent's toast opens it where everyone else finds it.
+ onViewListing: (agent: SharedAgent) => {
+ if (connection.status !== "ready") return;
+ sharedTrigger.current = null;
+ changeTab("marketplace");
+ setSharedSelection({
+ agent,
+ session: connection.session,
+ community: connection.origin,
+ });
+ },
};
return connection.status === "ready" ? (
{
+ setSharedSelection(undefined);
+ returnToAgents();
+ browse(agent.pubkey);
+ }}
/>
),
},
@@ -684,6 +714,7 @@ type InventoryProps = {
defaultChoices?: ReadonlySet;
/** The community connection Share publishes through, while it is ready. */
session?: RelaySession;
+ onViewListing?: ((agent: SharedAgent) => void) | undefined;
};
/** The inventory with this community's default agent marked and settable. */
@@ -787,6 +818,7 @@ function Inventory({
defaultChoices,
onMakeDefault,
session,
+ onViewListing,
}: InventoryProps) {
const grid = useRef(null);
const index = agents.findIndex((agent) => agent.pubkey === selected);
@@ -874,6 +906,7 @@ function Inventory({
service={service}
session={session}
type={type}
+ onViewListing={onViewListing}
onBuild={() => onBuild(agent.pubkey)}
onRemoved={() => onSelect()}
role="gridcell"
@@ -922,6 +955,7 @@ function InventoryCard({
onBuild,
onRemoved,
main,
+ onViewListing,
...props
}: Omit, "agent"> & {
agent: Agent;
@@ -930,6 +964,7 @@ function InventoryCard({
type: RegisteredAgentType | undefined;
onBuild(): void;
onRemoved(): void;
+ onViewListing?: ((agent: SharedAgent) => void) | undefined;
/** Whether it is the community's main agent and can be made it. */
main?:
| { isMain: boolean; pinned: boolean; available: boolean; onMake(): void }
@@ -1071,6 +1106,7 @@ function InventoryCard({
agent={agent}
type={type}
session={session}
+ onViewListing={onViewListing}
onClose={() => setSharing(false)}
/>
)}
diff --git a/src/bundled/agents/index.tsx b/src/bundled/agents/index.tsx
index 84b77c5b0d..3ecd195767 100644
--- a/src/bundled/agents/index.tsx
+++ b/src/bundled/agents/index.tsx
@@ -3,6 +3,7 @@ import type { PluginModule } from "../../plugins/api";
import { registerAttention } from "../../features/agents/attention-command";
import { registerDiscovery } from "../../features/agents/attention-discovery";
import { AgentsPage } from "./AgentsPage";
+import { AgentCardAttachment, isAgentCard } from "./AgentCardAttachment";
import { availableAgentProviderRoute } from "./routes";
import { developerMode } from "../../features/developer/mode";
import { withImportScenario } from "../../features/developer/agent-import-scenario";
@@ -14,12 +15,26 @@ export const inject = [
"navigation",
"communityReader",
"host",
+ "conversation",
];
export const apply: PluginModule["apply"] = (ctx) => {
registerAttention(ctx);
registerDiscovery(ctx.agents);
const { relay, navigation: navigator } = ctx;
const agents = developerMode ? withImportScenario(ctx.agents) : ctx.agents;
+ ctx.conversation.registerAttachment({
+ id: "agent-card",
+ title: "Agent card",
+ matches: isAgentCard,
+ component: (props) => (
+
+ ),
+ });
ctx.pages.register({
id: "attention",
title: "Agent Attention",
diff --git a/src/bundled/agents/routes.test.ts b/src/bundled/agents/routes.test.ts
index f400420b72..ab26f34a62 100644
--- a/src/bundled/agents/routes.test.ts
+++ b/src/bundled/agents/routes.test.ts
@@ -2,6 +2,8 @@ import { expect, it } from "vitest";
import {
agentProviderRoute,
agentProviderTarget,
+ agentRoute,
+ agentTarget,
availableAgentProviderRoute,
} from "./routes";
@@ -32,3 +34,16 @@ it("admits provider-shaped routes and preserves the Agents page scope", () => {
});
expect(agentProviderTarget(opened)).toEqual(base);
});
+
+it("admits an agent of yours to open, by its key", () => {
+ const pubkey = "a".repeat(64);
+ expect(agentRoute(agentTarget(pubkey).route.params)).toBe(pubkey);
+ expect(availableAgentProviderRoute({ agent: pubkey })).toBe(true);
+ for (const invalid of [
+ { agent: "Ada" },
+ { agent: pubkey, provider: "test/echo" },
+ { agent: 1 },
+ ] as const)
+ expect(agentRoute(invalid)).toBeUndefined();
+ expect(agentProviderRoute({ agent: pubkey })).toBeUndefined();
+});
diff --git a/src/bundled/agents/routes.ts b/src/bundled/agents/routes.ts
index daf456ca82..e9aa2d673f 100644
--- a/src/bundled/agents/routes.ts
+++ b/src/bundled/agents/routes.ts
@@ -15,8 +15,31 @@ export function agentProviderRoute(value: JsonValue | undefined) {
: undefined;
}
+/** An agent of yours to open, as `{ agent: pubkey }`. */
+export function agentRoute(value: JsonValue | undefined) {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
+ const keys = Object.keys(value);
+ const agent = (value as Readonly>).agent;
+ return keys.length === 1 &&
+ keys[0] === "agent" &&
+ typeof agent === "string" &&
+ /^[0-9a-f]{64}$/.test(agent)
+ ? agent
+ : undefined;
+}
+
export const availableAgentProviderRoute = (value: JsonValue) =>
- Boolean(agentProviderRoute(value));
+ Boolean(agentProviderRoute(value) ?? agentRoute(value));
+
+/** The Agents page with `pubkey` open. */
+export const agentTarget = (pubkey: string) =>
+ ({
+ version: 1,
+ kind: "page",
+ pluginId: "buzz.agents",
+ pageId: "agents",
+ route: { version: 1, params: { agent: pubkey } },
+ }) as const;
export function agentProviderTarget(
target: PageTarget,
diff --git a/src/bundled/channels/OutboxStatus.test.tsx b/src/bundled/channels/OutboxStatus.test.tsx
index fa3fce9dea..34a7a810b8 100644
--- a/src/bundled/channels/OutboxStatus.test.tsx
+++ b/src/bundled/channels/OutboxStatus.test.tsx
@@ -88,7 +88,7 @@ it("preserves generic retry for legacy unguarded invitations", () => {
// The recovery page deliberately has no source inventory or encoder: deleted
// agents and teams must not be needed to finish an already journaled delivery.
-it.each(["agent", "team", "solo"] as const)(
+it.each(["agent", "team", "solo", "agent-share"] as const)(
"retries and acknowledges a restored %s share without its deleted source",
async (kind) => {
const viewer = keypair();
@@ -129,10 +129,13 @@ it.each(["agent", "team", "solo"] as const)(
key:
kind === "solo"
? "solo-share-link:deleted-source:dm"
- : // Shares queued by the removed snapshot dialog.
- `snapshot-share:${kind}:deleted-source`,
+ : kind === "agent-share"
+ ? // Sent from Share, for one copy and recipient.
+ `agent-share:${"a".repeat(64)}:${"b".repeat(64)}:${"0".repeat(40)}`
+ : // Shares queued by the removed snapshot dialog.
+ `snapshot-share:${kind}:deleted-source`,
value:
- kind === "solo"
+ kind === "solo" || kind === "agent-share"
? "1"
: JSON.stringify({
people: [{ pubkey: "b".repeat(64), name: "Recipient" }],
diff --git a/src/bundled/channels/OutboxStatus.tsx b/src/bundled/channels/OutboxStatus.tsx
index fce1dbc0f8..f98a2dfa13 100644
--- a/src/bundled/channels/OutboxStatus.tsx
+++ b/src/bundled/channels/OutboxStatus.tsx
@@ -73,8 +73,8 @@ export function OutboxStatus({
try {
const shareReceipt =
item.event.kind === 9 &&
- // Saved receipts from earlier share flows.
- /^(snapshot-share:(agent|team)|solo-share-link:[^:]+):.+$/.test(
+ // Share receipts, including those of earlier flows.
+ /^(snapshot-share:(agent|team)|solo-share-link:[^:]+|agent-share:[^:]+):.+$/.test(
item.recovery?.key ?? "",
) &&
(item.delivery === "accepted" ||
diff --git a/src/features/agents/agent-share.ts b/src/features/agents/agent-share.ts
index c22c4b714a..6c015250c4 100644
--- a/src/features/agents/agent-share.ts
+++ b/src/features/agents/agent-share.ts
@@ -15,7 +15,7 @@ import type { SharedAgent } from "./shared-agents";
export type SharedDefinition = Omit;
const MAX_JSON_BYTES = 5 * 1024 * 1024;
-const MAX_PNG_BYTES = 10 * 1024 * 1024;
+export const MAX_PNG_BYTES = 10 * 1024 * 1024;
const PNG = [137, 80, 78, 71, 13, 10, 26, 10];
const KEYWORD = new TextEncoder().encode("buzz_agent_snapshot\0");
const decoder = new TextDecoder("utf-8", { fatal: true });
diff --git a/src/features/agents/avatar-packs.ts b/src/features/agents/avatar-packs.ts
index 6319619feb..b5b648b6e3 100644
--- a/src/features/agents/avatar-packs.ts
+++ b/src/features/agents/avatar-packs.ts
@@ -12,6 +12,11 @@ export const agentAvatars = catalog.assets.map((asset) => ({
preview: posters[`./avatar-packs/${asset.id}.png`],
}));
+/** The bundled copy of pack art, which can be read back from a canvas: the
+ * CDN sends no CORS header. */
+export const bundledPoster = (src: string) =>
+ agentAvatars.find((avatar) => avatar.url === src.split("#", 1)[0])?.preview;
+
/** Animations stream from the catalog's CDN; the bundled poster covers offline use. */
export function avatarAnimation(id: string) {
const animations = catalog.assets.find(
diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx
index fe293765d4..334bdc731f 100644
--- a/src/features/messages/MessageRow.test.tsx
+++ b/src/features/messages/MessageRow.test.tsx
@@ -908,6 +908,65 @@ it("renders proxy audio attachments with an inline player", () => {
expect(html).not.toContain("Download file");
});
+it("lets a plugin draw an image it claims, apart from the gallery", () => {
+ const card = {
+ id: "card",
+ title: "Card",
+ key: "test.plugin/card",
+ pluginId: "test.plugin",
+ revision: "one",
+ matches: (attachment: { name?: string }) =>
+ attachment.name === "Ada.agent.png",
+ component: ({ source }: { source: string }) => (
+ card from {source}
+ ),
+ };
+ const cards = [card];
+ const html = renderToStaticMarkup(
+ `/api/relay/media?url=${encodeURIComponent(url)}`}
+ onOpenLink={() => false}
+ day={false}
+ retry={undefined}
+ extensions={{
+ tools: { snapshot: () => [], subscribe: () => () => {} },
+ inline: { snapshot: () => [], subscribe: () => () => {} },
+ attachments: { snapshot: () => cards, subscribe: () => () => {} },
+ }}
+ />,
+ );
+ expect(html).toContain(
+ "card from /api/relay/media?url=https%3A%2F%2Fimage.test%2Fada.png",
+ );
+ // The images around it stay pictures, each in its own group.
+ const container = document.createElement("div");
+ container.innerHTML = html;
+ expect(
+ [...container.querySelectorAll("[data-message-attachments] > *")].map(
+ (group) =>
+ group.querySelector('[role="group"]')?.getAttribute("aria-label") ??
+ group.textContent,
+ ),
+ ).toEqual([
+ "1 image",
+ "card from /api/relay/media?url=https%3A%2F%2Fimage.test%2Fada.png",
+ "1 image",
+ ]);
+});
+
it("renders external audio sources as open file cards", () => {
const html = renderToStaticMarkup(
+ attachment.kind === "image" &&
+ attachmentRenderers.some((renderer) => {
+ try {
+ return renderer.matches(attachment);
+ } catch {
+ return false;
+ }
+ });
// Gallery-backed images and videos share a group, preserving sender order.
const attachmentGroups: ChannelMessage["attachments"][number][][] = [];
for (const attachment of row.attachments) {
if (!safeMessageUrl(attachment.url) || claimed(attachment)) continue;
const previous = attachmentGroups.at(-1);
if (
- ((attachment.kind === "image" || attachment.kind === "file") &&
- previous?.[0]?.kind === attachment.kind) ||
- (onOpenMediaReview &&
- (attachment.kind === "image" || attachment.kind === "video") &&
- (previous?.[0]?.kind === "image" || previous?.[0]?.kind === "video"))
+ previous?.[0] &&
+ !drawn(attachment) &&
+ !drawn(previous[0]) &&
+ (((attachment.kind === "image" || attachment.kind === "file") &&
+ previous[0].kind === attachment.kind) ||
+ (onOpenMediaReview &&
+ (attachment.kind === "image" || attachment.kind === "video") &&
+ (previous[0].kind === "image" || previous[0].kind === "video")))
)
previous.push(attachment);
else attachmentGroups.push([attachment]);
@@ -1122,7 +1141,7 @@ function MessageRowContent({
);
}
if (attachment.kind === "image") {
- return (
+ const image = (
);
+ return source &&
+ extensions?.attachments &&
+ drawn(attachment) ? (
+
+ ) : (
+ image
+ );
}
return (
);
});
- return images && (imageOnly || group.length >= 4) ? (
+ return images &&
+ !(group[0] && drawn(group[0])) &&
+ (imageOnly || group.length >= 4) ? (
{/* biome-ignore lint/a11y/useSemanticElements: This labels related media links, not a fieldset of form controls. */}
- agentAvatars.find((avatar) => avatar.url === src.split("#", 1)[0])?.preview ??
- src;
+const bundled = (src: string) => bundledPoster(src) ?? src;
function samplePalette(src: string): Promise {
const result = new Promise((resolve) => {