From 5c5f7b57c0017d835503fc19b54e7fc470806879 Mon Sep 17 00:00:00 2001 From: tulsi Date: Wed, 28 Oct 2026 09:59:47 -0400 Subject: [PATCH 01/15] fix(agents): export pack avatars in .agent.png snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pack avatars live on a CDN that sends no CORS header, so the export canvas couldn't read them and every such agent exported a blank 1×1 image. Draw the app's bundled copy instead, as the avatar palette already does. Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: tulsi --- src/bundled/agents/AgentShareDialog.test.tsx | 28 +++++++++++++++++++ src/bundled/agents/AgentShareDialog.tsx | 5 +++- src/features/agents/avatar-packs.ts | 5 ++++ .../composer-models/avatar-palette.ts | 7 ++--- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/bundled/agents/AgentShareDialog.test.tsx b/src/bundled/agents/AgentShareDialog.test.tsx index 84f9e19a86..eb7962de56 100644 --- a/src/bundled/agents/AgentShareDialog.test.tsx +++ b/src/bundled/agents/AgentShareDialog.test.tsx @@ -10,6 +10,7 @@ import { import userEvent from "@testing-library/user-event"; import { afterEach, 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"; @@ -424,6 +425,33 @@ 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")); + }, + ); + render( + {}} + />, + ); + fireEvent.click(screen.getByRole("button", { name: "Export agent" })); + expect(await screen.findByRole("status")).toHaveTextContent("Exported Ada."); + 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; diff --git a/src/bundled/agents/AgentShareDialog.tsx b/src/bundled/agents/AgentShareDialog.tsx index 50ed11883a..7902cd69a6 100644 --- a/src/bundled/agents/AgentShareDialog.tsx +++ b/src/bundled/agents/AgentShareDialog.tsx @@ -14,6 +14,7 @@ import type { Agent, RegisteredAgentType } from "../../features/agents/service"; import type { EventData } from "../../features/relay/events"; import { downloadNativeFile } from "../../features/messages/native-download"; 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"; @@ -35,9 +36,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; 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/composer-models/avatar-palette.ts b/src/features/messages/composer-models/avatar-palette.ts index aca7db811d..9ae26f0d7c 100644 --- a/src/features/messages/composer-models/avatar-palette.ts +++ b/src/features/messages/composer-models/avatar-palette.ts @@ -1,11 +1,8 @@ // Decorative colors come from the avatar artwork, with a theme fallback for unreadable images. import { useEffect, useState } from "react"; -import { agentAvatars } from "../../agents/avatar-packs"; +import { bundledPoster } from "../../agents/avatar-packs"; -// Pack art ships with the app; read that copy, since the CDN sends no CORS header. -const bundled = (src: string) => - 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) => { From 53b5cc0a3735713576a64aa3982f3090c8ea9f19 Mon Sep 17 00:00:00 2001 From: tulsi Date: Wed, 28 Oct 2026 10:11:52 -0400 Subject: [PATCH 02/15] feat(agents): rework the Share window into Marketplace and Export rows Publishing and removing now go through a review step inside the same window that shows the instructions being shared, instead of a switch that publishes immediately. Each row keeps its own status and errors, so a Marketplace read failure no longer blocks exporting. Export saves the picture card by default, with JSON as a fallback link. Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: tulsi --- .../agents/AgentShareDialog.module.css | 5 + src/bundled/agents/AgentShareDialog.test.tsx | 338 ++++++++-------- src/bundled/agents/AgentShareDialog.tsx | 368 +++++++++++++----- 3 files changed, 441 insertions(+), 270 deletions(-) create mode 100644 src/bundled/agents/AgentShareDialog.module.css diff --git a/src/bundled/agents/AgentShareDialog.module.css b/src/bundled/agents/AgentShareDialog.module.css new file mode 100644 index 0000000000..4eb01b577c --- /dev/null +++ b/src/bundled/agents/AgentShareDialog.module.css @@ -0,0 +1,5 @@ +/* Long instructions scroll inside the review instead of growing the dialog. */ +.instructions { + max-height: 16rem; + overflow-y: auto; +} diff --git a/src/bundled/agents/AgentShareDialog.test.tsx b/src/bundled/agents/AgentShareDialog.test.tsx index eb7962de56..e6636e5d64 100644 --- a/src/bundled/agents/AgentShareDialog.test.tsx +++ b/src/bundled/agents/AgentShareDialog.test.tsx @@ -107,33 +107,56 @@ function community() { }; } -it("shares the agent to its community's catalog and unshares what it shared", async () => { - const user = userEvent.setup(); - const { session, outbox, accept } = community(); - render( +function share(session: RelaySession, overrides: Partial = {}) { + return render( {}} />, ); - const dialog = screen.getByRole("dialog", { name: "Share Ada" }); - const toggle = within(dialog).getByRole("switch", { - name: "Share to catalog", +} + +/** The Marketplace row's action, once the listing has been read. */ +async function listingAction(name: string) { + const button = await screen.findByRole("button", { name }); + await vi.waitFor(() => expect(button).toBeEnabled()); + return button; +} + +const sharedContent = JSON.stringify({ + display_name: "Ada", + system_prompt: "Review pull requests.", + acp_command: "buzz-acp", + runtime: "echo", + model: "echo-1", +}); + +it("publishes what the review shows, then removes what it published", async () => { + const user = userEvent.setup(); + const { session, outbox, accept } = community(); + share(session); + expect( + await within(screen.getByRole("dialog", { name: "Share Ada" })).findByText( + "Not listed", + ), + ).toBeInTheDocument(); + await user.click(await listingAction("Publish Ada to the Marketplace")); + const review = screen.getByRole("dialog", { + name: "Publish Ada to the Marketplace", }); - await vi.waitFor(() => expect(toggle).not.toHaveAttribute("aria-disabled")); - expect(toggle).not.toBeChecked(); - await user.click(toggle); + expect( + within(review).getByRole("region", { name: "Instructions" }), + ).toHaveTextContent("Review pull requests."); + expect(review).toHaveTextContent( + "Memories, chats, and saved logins aren’t included.", + ); + // Nothing is published until the review is confirmed. + expect(outbox.send).not.toHaveBeenCalled(); + await user.click(within(review).getByRole("button", { name: "Publish" })); 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", - }); + expect(within(review).getByRole("button", { name: "Cancel" })).toBeDisabled(); expect(outbox.send).toHaveBeenLastCalledWith( { kind: 30175, @@ -148,13 +171,22 @@ it("shares the agent to its community's catalog and unshares what it shared", as ); accept(); expect( - await within(dialog).findByText("Published Ada to the community catalog."), + await screen.findByText("Published to the Marketplace."), ).toBeInTheDocument(); - expect(toggle).toBeChecked(); + const remove = screen.getByRole("button", { + name: "Remove Ada from the Marketplace", + }); + // Returning lands on the row's action that replaced Publish. + expect(remove).toHaveFocus(); - await user.click(toggle); + await user.click(remove); + const confirm = screen.getByRole("dialog", { + name: "Remove Ada from the Marketplace?", + }); + expect(confirm).toHaveTextContent("People who already added a copy keep it."); + await user.click(within(confirm).getByRole("button", { name: "Remove" })); await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(2)); - // Unsharing replaces the shared head with what it shared. + // Removing replaces the shared head with what it shared. expect(outbox.send).toHaveBeenLastCalledWith( { kind: 30175, @@ -167,147 +199,135 @@ 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 from the Marketplace."), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Publish Ada to the Marketplace" }), ).toBeInTheDocument(); - expect(toggle).not.toBeChecked(); }); -it("says why sharing failed and keeps the catalog as it was", async () => { +it("goes back from the review without publishing", async () => { const user = userEvent.setup(); const { session, outbox } = community(); - render( - {}} - />, - ); - 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.", + share(session); + const publish = await listingAction("Publish Ada to the Marketplace"); + await user.click(publish); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.getByRole("dialog", { name: "Share Ada" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Publish Ada to the Marketplace" }), + ).toHaveFocus(); + expect(outbox.send).not.toHaveBeenCalled(); +}); + +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("Not listed"); + expect( + screen.getByRole("button", { name: "Publish Ada to the Marketplace" }), + ).toBeDisabled(); + expect(screen.getByRole("button", { name: "Export Ada" })).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Export as JSON instead" }), + ).toBeDisabled(); expect(outbox.send).not.toHaveBeenCalled(); - expect(toggle).not.toBeChecked(); }); it("asks the relay what an unanswered publish did before saying it failed", async () => { const user = userEvent.setup(); const { session, outbox, lose } = community(); - render( - {}} - />, - ); - const toggle = screen.getByRole("switch", { name: "Share to catalog" }); - await vi.waitFor(() => expect(toggle).not.toHaveAttribute("aria-disabled")); - await user.click(toggle); + share(session); + await user.click(await listingAction("Publish Ada to the Marketplace")); + await user.click(screen.getByRole("button", { name: "Publish" })); await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(1)); lose(true); expect( - await screen.findByText("Published Ada to the community catalog."), + await screen.findByText("Published to the Marketplace."), ).toBeInTheDocument(); - expect(toggle).toBeChecked(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - await user.click(toggle); + await user.click( + screen.getByRole("button", { name: "Remove Ada from the Marketplace" }), + ); + await user.click(screen.getByRole("button", { name: "Remove" })); await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(2)); lose(false); + // The failure stays with the confirmation, ready to try again. expect(await screen.findByRole("alert")).toHaveTextContent( - "Failed to update catalog sharing. Try again.", + "Couldn’t remove Ada. Try again.", ); - expect(toggle).toBeChecked(); + expect(screen.getByRole("button", { name: "Remove" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.getByText("Listed")).toBeInTheDocument(); }); -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 opens the confirmation to remove it. */ +async function publishThenConfirmRemove( + user: ReturnType, + { outbox, accept }: ReturnType, +) { + await user.click(await listingAction("Publish Ada to the Marketplace")); + await user.click(screen.getByRole("button", { name: "Publish" })); await vi.waitFor(() => expect(outbox.send).toHaveBeenCalledTimes(1)); accept(); - await vi.waitFor(() => expect(toggle).toBeChecked()); - - await user.click(toggle); + await user.click( + await screen.findByRole("button", { + name: "Remove Ada from the Marketplace", + }), + ); + await user.click(screen.getByRole("button", { name: "Remove" })); 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 publishThenConfirmRemove(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(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.getByText("Listed")).toBeInTheDocument(); }); 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 publishThenConfirmRemove(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(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.getByText("Listed")).toBeInTheDocument(); }); -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" }); + share(session); expect(await screen.findByRole("alert")).toHaveTextContent( - "Could not read the community catalog. Check the community connection, then retry.", + "Couldn’t check the Marketplace.", ); - expect(toggle).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Export Ada" })).toBeEnabled(); await user.click(screen.getByRole("button", { name: "Try again" })); - await vi.waitFor(() => expect(toggle).not.toHaveAttribute("aria-disabled")); + await listingAction("Publish Ada to the Marketplace"); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); @@ -336,10 +356,10 @@ it("exports the agent as an .agent.json snapshot", async () => { onClose={() => {}} />, ); - 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 as JSON instead" }), + ); + expect(await screen.findByText("Exported.")).toBeInTheDocument(); expect(click).toHaveBeenCalledTimes(1); expect(JSON.parse((await blobs[0]?.text()) ?? "")).toEqual({ format: "buzz-agent-snapshot", @@ -376,8 +396,8 @@ it("names a desktop export so every platform accepts it", async () => { onClose={() => {}} />, ); - await user.click(screen.getByRole("button", { name: "Export agent" })); - expect(await screen.findByRole("status")).toHaveTextContent("Exported"); + await user.click(screen.getByRole("button", { name: `Export ${name}` })); + expect(await screen.findByText("Exported.")).toBeInTheDocument(); expect(desktop.invoke.mock.lastCall?.[2]).toEqual({ headers: { "x-buzz-file-name": file }, }); @@ -408,14 +428,12 @@ it("exports without the picture when it does not load in time", async () => { ); // 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(screen.getByRole("button", { name: "Export Ada" })); await vi.advanceTimersByTimeAsync(4999); expect(desktop.invoke).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); vi.useRealTimers(); - expect(await screen.findByRole("status")).toHaveTextContent( - "Exported Ada.", - ); + expect(await screen.findByText("Exported.")).toBeInTheDocument(); expect( await readSnapshot(new Blob([desktop.invoke.mock.calls[0]?.[1]])), ).toMatchObject({ name: "Ada", picture: "https://media.example/ada.png" }); @@ -447,8 +465,8 @@ it("draws pack art from the app's own copy, since its CDN can't be read back", a onClose={() => {}} />, ); - fireEvent.click(screen.getByRole("button", { name: "Export agent" })); - expect(await screen.findByRole("status")).toHaveTextContent("Exported Ada."); + fireEvent.click(screen.getByRole("button", { name: "Export Ada" })); + expect(await screen.findByText("Exported.")).toBeInTheDocument(); expect(sources).toEqual([pack.preview]); }); @@ -466,14 +484,11 @@ it("saves an .agent.png natively on desktop, never through a blob link", async ( onClose={() => {}} />, ); - expect( - screen.getByRole("combobox", { name: "File format" }), - ).toHaveTextContent("PNG"); - await user.click(screen.getByRole("button", { name: "Export agent" })); + await user.click(screen.getByRole("button", { name: "Export Ada" })); // 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 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] ?? []; @@ -493,7 +508,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({ @@ -507,34 +522,25 @@ 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 user.click(await listingAction("Update Ada in the Marketplace")); + const review = screen.getByRole("dialog", { + name: "Update Ada in the Marketplace", + }); + expect( + within(review).getByRole("region", { name: "Instructions" }), + ).toHaveTextContent("Review pull requests."); + await user.click(within(review).getByRole("button", { name: "Update" })); 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"], @@ -546,12 +552,12 @@ 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 to the Marketplace."), ).toBeInTheDocument(); expect( - within(dialog).queryByRole("button", { name: "Update share" }), + screen.queryByRole("button", { name: "Update Ada in the Marketplace" }), ).not.toBeInTheDocument(); expect( - within(dialog).getByRole("switch", { name: "Share to catalog" }), - ).toBeChecked(); + screen.getByRole("button", { name: "Remove Ada from the Marketplace" }), + ).toBeInTheDocument(); }); diff --git a/src/bundled/agents/AgentShareDialog.tsx b/src/bundled/agents/AgentShareDialog.tsx index 7902cd69a6..3d5362fb1b 100644 --- a/src/bundled/agents/AgentShareDialog.tsx +++ b/src/bundled/agents/AgentShareDialog.tsx @@ -1,6 +1,6 @@ -// Share: the agent's catalog listing in its community, and a file export. +// Share: the agent's Marketplace listing in its community, and a file export. import { isTauri } from "@tauri-apps/api/core"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { catalogContent, catalogHead, @@ -18,16 +18,16 @@ 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 { InlineHeader } from "../../shared/design-system/ui/Header"; +import { PreferenceRow } from "../../shared/design-system/ui/PreferenceRow"; import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup"; -import { SwitchPreferenceRow } from "../../shared/design-system/ui/SwitchPreferenceRow"; +import { AgentPortrait } from "./AgentCard"; +import browseStyles from "./AgentBrowse.module.css"; +import pageStyles from "./AgentsPage.module.css"; +import styles from "./AgentShareDialog.module.css"; const message = (error: unknown) => error instanceof Error ? error.message : String(error); -const FORMATS = [ - { value: "png", label: "PNG" }, - { value: "json", label: "JSON" }, -]; /** How long export waits for the picture, which keeps the dialog open. */ const ARTWORK_MS = 5000; @@ -81,7 +81,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"); @@ -92,6 +92,26 @@ async function save(file: Blob, name: string): Promise { setTimeout(() => URL.revokeObjectURL(url), 1000); } +/** A row's latest outcome, which replaces its status line. */ +type Outcome = { text: string; failed?: boolean }; + +/** The status line a row announces, and an outcome in its place. */ +function Status({ + line, + outcome, +}: { + line: string; + outcome?: Outcome | undefined; +}) { + return outcome?.failed ? ( + + {outcome.text} + + ) : ( + {outcome?.text ?? line} + ); +} + export function AgentShareDialog({ agent, type, @@ -105,10 +125,12 @@ export function AgentShareDialog({ }) { // `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 [error, setError] = useState(""); - const [format, setFormat] = useState<"png" | "json">("png"); + const [pending, setPending] = useState<"listing" | "download">(); + const [listing, setListing] = useState(); + const [download, setDownload] = useState(); + // Publishing and removing are reviewed in a step of this dialog. + const [view, setView] = useState<"share" | "publish" | "remove">("share"); + const [failure, setFailure] = useState(""); // The read failed; clearing it reads again. const [unread, setUnread] = useState(false); const attempt = useRef(null); @@ -122,21 +144,45 @@ export function AgentShareDialog({ ); return () => controller.abort(); }, [session, agent.owner, agent.pubkey, unread]); + // A step starts on its Cancel; returning lands on the Marketplace row's + // action, which may have changed with what the step did. + const cancel = useRef(null); + const actions = useRef(null); + const previousView = useRef(view); + useLayoutEffect(() => { + if (view !== previousView.current) + (view === "share" + ? actions.current?.querySelector("button") + : cancel.current + )?.focus(); + previousView.current = view; + }, [view]); + // 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; const share = async (shared: boolean) => { const signal = attempt.current?.signal; if (head === null || pending || !signal) return; - setPending(true); - setNotice(""); - setError(""); - let failure = "Try again."; + setPending("listing"); + setListing(undefined); + setFailure(""); + let reason = "Try again."; 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 = !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 @@ -149,113 +195,227 @@ export function AgentShareDialog({ ).catch(() => head); if (signal.aborted) return; setHead(next); - if (isShared(next) === shared) - setNotice( - shared - ? `Published ${agent.name} to the community catalog.` - : `${agent.name} is no longer discoverable in the community catalog.`, + setPending(undefined); + if (isShared(next) === shared) { + setListing({ + text: shared + ? "Published to the Marketplace." + : "Removed from the Marketplace.", + }); + setView("share"); + } else + setFailure( + `Couldn’t ${shared ? "publish" : "remove"} ${agent.name}. ${reason}`, ); - else setError(`Failed to update catalog sharing. ${failure}`); - setPending(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(""); - setError(""); + const save = async (format: "png" | "json") => { + if (pending || !definition) return; + setPending("download"); + setDownload(undefined); 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 path = await saveFile(file, fileName(agent.name, format)); + setDownload({ text: path ? `Saved to ${path}.` : "Exported." }); } catch (reason) { - setError(`Failed to export agent snapshot. ${message(reason)}`); + setDownload({ + text: `Couldn’t export ${agent.name}. ${message(reason)}`, + failed: true, + }); } - setPending(false); + setPending(undefined); }; + const back = () => { + setFailure(""); + setView("share"); + }; + const busy = !!pending; + if (view !== "share") { + const publishing = view === "publish"; + const title = publishing + ? `${stale ? "Update" : "Publish"} ${agent.name} ${stale ? "in" : "to"} the Marketplace` + : `Remove ${agent.name} from the Marketplace?`; + return ( + { + if (!open) back(); + }} + actions={ + <> + + + + } + > + {(publishing || failure) && ( +
+ {publishing && ( +
+ +
+ {definition?.instructions || + "This agent has no instructions."} +
+

+ Memories, chats, and saved logins aren’t included. +

+
+ )} + {failure && ( +

+ {failure} +

+ )} +
+ )} +
+ ); + } return (