From 014af6af16944602522c79ab60a92147bb444fba Mon Sep 17 00:00:00 2001 From: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Date: Thu, 24 Sep 2026 22:51:49 -0400 Subject: [PATCH 1/3] Set up incomplete inventory identities through a working Use here dialog Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- src/bundled/agents/AgentControlPanel.test.tsx | 111 +++++++++ src/bundled/agents/AgentControlPanel.tsx | 79 ++++++- src/bundled/agents/AgentsPage.test.tsx | 6 +- src/bundled/agents/AgentsPage.tsx | 15 +- src/bundled/agents/InventoryIdentityCard.tsx | 55 ++++- src/bundled/agents/InventoryView.test.tsx | 218 ++++++++++++++++++ src/bundled/agents/InventoryView.tsx | 6 +- src/bundled/agents/ManagedAgentActions.tsx | 11 +- src/bundled/agents/UnifiedInventory.test.tsx | 1 + src/bundled/agents/UnifiedInventory.tsx | 3 + src/bundled/agents/inventory-decisions.ts | 12 +- 11 files changed, 500 insertions(+), 17 deletions(-) create mode 100644 src/bundled/agents/AgentControlPanel.test.tsx create mode 100644 src/bundled/agents/InventoryView.test.tsx diff --git a/src/bundled/agents/AgentControlPanel.test.tsx b/src/bundled/agents/AgentControlPanel.test.tsx new file mode 100644 index 000000000..86dc77f46 --- /dev/null +++ b/src/bundled/agents/AgentControlPanel.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing"; +stubAvatarBrowserApis(); +import "@testing-library/jest-dom/vitest"; +import { afterEach, expect, it, vi } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import * as communityApi from "../../features/communities/api"; +import { createAgentControl } from "../../features/agents/control"; +import { controlFixture } from "../../features/agents/control-testing"; +import { AgentControlPanel } from "./AgentControlPanel"; + +const disposals: (() => void)[] = []; +afterEach(() => { + cleanup(); + for (const dispose of disposals.splice(0)) dispose(); + vi.restoreAllMocks(); +}); + +// Exercise the control panel callbacks before the replacement inventory mounts. +function setup( + _mode: string, + configure: (f: ReturnType) => void, +) { + const f = controlFixture(); + configure(f); + const control = createAgentControl(f.host); + disposals.push(() => control.dispose()); + render( + + {(state, _edit, _duplicate, _remove, _importedId, _label, onUseHere) => + state.status === "ready" && ( + <> +
+ +
+ + ) + } +
, + ); + return { f }; +} + +it("dialog actions choose the destination record when several local setups share a key", async () => { + const request = vi + .spyOn(communityApi, "communityRequest") + .mockImplementation(async (_destination, route) => + route === "agent-inventory" + ? { identities: [] } + : { + pubkey: "ab".repeat(32), + relayUrl: "wss://relay.example.test", + owner: "de".repeat(32), + signature: "fixture", + }, + ); + const { f } = setup("connected", (fixture) => { + fixture.data.parked = []; + fixture.agent.configured = false; + fixture.agent.enabled = false; + fixture.agent.status = "stopped"; + fixture.data.agents.unshift({ + ...structuredClone(fixture.agent), + id: "first-other-community", + relayUrl: "wss://elsewhere.example", + name: "Other saved name", + systemPrompt: "Other instructions", + }); + }); + const card = await screen.findByRole("article", { + name: "Agent Fixture agent", + }); + fireEvent.click(within(card).getByRole("button", { name: "Use here" })); + const setupDialog = await screen.findByRole("dialog", { + name: "Set up agent here", + }); + fireEvent.click( + within(setupDialog).getByRole("button", { name: "Use here" }), + ); + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: "Set up agent here" }), + ).toBeNull(), + ); + expect( + f.calls.find((call) => call.action === "configure")?.payload, + ).toMatchObject({ id: "fixture-agent" }); + expect(request).toHaveBeenCalledWith( + "https://relay.example.test", + "resolve-agent-community", + { pubkey: f.agent.pubkey, owner: "de".repeat(32), confirmed: true }, + ); + expect( + f.data.agents.find((agent) => agent.id === "first-other-community") + ?.configured, + ).toBe(false); + expect(f.agent.enabled).toBe(false); +}); diff --git a/src/bundled/agents/AgentControlPanel.tsx b/src/bundled/agents/AgentControlPanel.tsx index 8205446a2..87b57d025 100644 --- a/src/bundled/agents/AgentControlPanel.tsx +++ b/src/bundled/agents/AgentControlPanel.tsx @@ -1,3 +1,6 @@ +import { Dialog } from "@base-ui/react/dialog"; +import { relayOrigin } from "../../features/communities/destination"; +import { LocalInventoryAction } from "./LocalInventoryAction"; import type { useIdentityNames } from "../../features/identity-names/react"; import { useAgentControl, @@ -49,6 +52,7 @@ export function AgentControlPanel({ remove: (agent: AgentView) => void, importedId: string | null, label: (agent: AgentView) => string, + onUseHere: (pubkey: string) => void, ) => ReactNode; }) { const [adding, setAdding] = useState<{ @@ -57,6 +61,17 @@ export function AgentControlPanel({ source?: AgentView; initialSettings?: CloneSettings; } | null>(null); + const [localPending, setLocalPending] = useState(false); + const [handover, setHandover] = useState<{ + pubkey: string; + destination: string; + } | null>(null); + useEffect(() => { + // A handover belongs to the community in which its action was selected. + setHandover((current) => + current?.destination === importDestination ? current : null, + ); + }, [importDestination]); const [importSections, setImportSections] = useState([]); const [importedId, setImportedId] = useState(null); const [selected, setSelected] = useState<{ @@ -76,7 +91,10 @@ export function AgentControlPanel({ }); const remove = (agent: AgentView) => setDeleting(agent.id); useAgentControlRefresh(control); - const state = useAgentControl(control); + const nativeState = useAgentControl(control); + const state = localPending + ? { ...nativeState, busy: true, pendingCredentialWrite: true } + : nativeState; useEffect(() => { if ( state.data?.agents.some( @@ -99,6 +117,16 @@ export function AgentControlPanel({ ...facts, { pubkey: agent.pubkey, name: agent.name, isAgent: true }, ]) ?? agent.name; + const localSource = + handover && + (state.data?.agents.find( + (agent) => + agent.pubkey === handover.pubkey && + !!agent.relayUrl && + !!handover.destination && + relayOrigin(agent.relayUrl) === relayOrigin(handover.destination), + ) ?? + state.data?.agents.find((agent) => agent.pubkey === handover.pubkey)); // Route selection takes precedence over card-local editing. Never guess among // multiple native records for the same public identity in this community. const routed = @@ -141,6 +169,7 @@ export function AgentControlPanel({ + + + + )} {adding && ( call.action === "configure")).toBe(false); - fireEvent.click(within(card).getByRole("button", { name: "Use here" })); + fireEvent.click(within(dialog).getByRole("button", { name: "Use here" })); await waitFor(() => expect(within(card).getByRole("button", { name: "Start" })).toBeEnabled(), ); diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index 8b3839c9b..c8defd8b0 100644 --- a/src/bundled/agents/AgentsPage.tsx +++ b/src/bundled/agents/AgentsPage.tsx @@ -140,7 +140,15 @@ export function AgentsPage({ : undefined } > - {(state, edit, duplicate, remove, importedId, label) => + {( + state, + edit, + duplicate, + remove, + importedId, + label, + onUseHere, + ) => state.status === "unavailable" ? ( library ) : state.data?.parked !== undefined ? ( @@ -153,9 +161,11 @@ export function AgentsPage({ importedId={importedId} control={control} connection={connection} + onUseHere={onUseHere} /> ) : ( ; state: AgentControlState; control: AgentControl; session: RelaySession; @@ -33,10 +38,12 @@ export function InventoryIdentityCard({ duplicate?: ((agent: AgentView) => void) | undefined; remove?: ((agent: AgentView) => void) | undefined; importedId: string | null; + onUseHere(pubkey: string): void; }) { const data = state.data; if (!data) return null; const avatar = row.avatar ?? publicProfiles.get(row.pubkey)?.picture; + const tile = decision.group === localHereGroup; // The app runs every saved setup, so each keeps its controls whether or not // its community is the one currently selected or connected. const setups = localSetups(row, destination); @@ -61,16 +68,46 @@ export function InventoryIdentityCard({ /> ))} {row.unconfiguredSetups.map((agent) => ( - +

+ Earlier import needs setup: {agent.relayUrl || "No saved community"}. + Connect to the intended community and choose Use here. It stays + stopped. +

))} +
+ {decision.action === "use" && ( + <> + + {decision.blocked &&

{decision.blocked}

} + {!data.localInventoryActions && ( +

+ Restart an updated desktop build to use local inventory actions. +

+ )} + + )} +
+ {decision.action === "wait" && ( +

+ {decision.blocked} +

+ )}
Identity & sources
diff --git a/src/bundled/agents/InventoryView.test.tsx b/src/bundled/agents/InventoryView.test.tsx new file mode 100644 index 000000000..ea615e424 --- /dev/null +++ b/src/bundled/agents/InventoryView.test.tsx @@ -0,0 +1,218 @@ +// @vitest-environment jsdom +import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing"; +stubAvatarBrowserApis(); +import "@testing-library/jest-dom/vitest"; +import { npubEncode } from "nostr-tools/nip19"; +import { afterEach, expect, it, vi } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { createAgentControl } from "../../features/agents/control"; +import { controlFixture } from "../../features/agents/control-testing"; +import { createRelaySession } from "../../features/relay/session"; +import { inventoryIdentities } from "./inventory-model"; +import { InventoryView } from "./InventoryView"; +const disposals: (() => void)[] = []; +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + for (const dispose of disposals.splice(0)) dispose(); +}); +function setup( + mode: string, + configure: (f: ReturnType) => void, + identities = ["cd".repeat(32)], + definitions: { id: string; name: string }[] = [], +) { + const f = controlFixture(); + configure(f); + const owned = createRelaySession({ + viewer: "de".repeat(32), + relayAuthor: "ef".repeat(32), + scope: "wss://relay.example.test", + query: async () => [], + media: () => undefined, + }); + const control = createAgentControl(f.host); + disposals.push(() => { + owned.dispose(); + control.dispose(); + }); + const rows = inventoryIdentities( + mode === "disconnected" + ? [] + : [ + { + pubkey: "cd".repeat(32), + name: "Not imported", + definitionId: "linked", + }, + ], + f.data, + (_key, fallback) => fallback, + ); + if (mode !== "disconnected") { + for (const key of identities) { + const row = rows.get(key); + if (row) + row.knownCommunities = new Set([ + ...row.knownCommunities, + "https://relay.example.test", + ]); + } + } + const onUseHere = vi.fn(); + const view = () => ( + {}} + importedId={null} + onUseHere={onUseHere} + /> + ); + const mounted = render(view()); + return { + f, + onUseHere, + rows, + redraw: () => mounted.rerender(view()), + }; +} +it("keeps parked discovery community unknown without claiming saved setup", async () => { + setup( + "connected", + (fixture) => { + fixture.data.parked = [ + { pubkey: "cd".repeat(32), name: "Saved", sources: ["installed"] }, + { pubkey: "ee".repeat(32), name: "Unresolved", sources: ["installed"] }, + ]; + }, + [], + ); + const group = await screen.findByRole("region", { + name: "Available to import", + }); + expect( + await within(group).findByRole("article", { name: "Agent Saved" }), + ).toBeVisible(); + expect( + within( + screen.getByRole("region", { name: "Available to import" }), + ).getByRole("article", { name: "Agent Unresolved" }), + ).toBeVisible(); +}); + +it("sorts displayed names within groups and profile cards without merging equal names", async () => { + const keys = ["11", "22", "33", "44"].map((s) => s.repeat(32)); + setup( + "connected", + (f) => { + f.data.agents = []; + f.data.parked = keys.map((pubkey, i) => ({ + pubkey, + name: ["Zebra", "beta", "Alpha", "Alpha"][i] ?? "", + sources: ["installed"], + })); + }, + keys, + [ + { id: "z", name: "Zebra profile" }, + { id: "a", name: "alpha profile" }, + ], + ); + const group = await screen.findByRole("region", { + name: "Available to import", + }); + const cards = within(group).getAllByRole("article"); + expect(cards.map((card) => card.getAttribute("aria-label"))).toEqual([ + "Agent Alpha", + "Agent Alpha", + "Agent beta", + "Agent Zebra", + ]); + expect( + cards.map((card) => card.querySelector("[data-public-key]")?.textContent), + ).toEqual(["33", "44", "22", "11"].map((key) => npubEncode(key.repeat(32)))); + const profiles = await screen.findByRole("region", { + name: "Profiles without identities", + }); + expect( + within(profiles) + .getAllByRole("article") + .map((card) => card.getAttribute("aria-label")), + ).toEqual(["Agent alpha profile", "Agent Zebra profile"]); + expect(within(group).getAllByRole("article")).toHaveLength(4); +}); + +it("renders four exclusive sections with all setups on one exact-key card", async () => { + setup("connected", (f) => { + const here = { ...f.agent }; + f.data.agents.push({ + ...here, + id: "same-key-other", + relayUrl: "wss://second.example", + }); + f.data.agents.push({ + ...here, + id: "other-key", + pubkey: "ee".repeat(32), + relayUrl: "wss://third.example", + }); + f.data.parked = [ + { + pubkey: here.pubkey.toUpperCase(), + name: "Shared", + sources: ["installed", "development"], + }, + { + pubkey: "ff".repeat(32), + name: "Importable", + sources: ["installed", "development"], + }, + ]; + }); + const local = await screen.findByRole("region", { + name: "Local agents in this community", + }); + const card = within(local).getByRole("article", { name: "Agent Shared" }); + expect(screen.getAllByRole("article", { name: "Agent Shared" })).toHaveLength( + 1, + ); + // Each saved setup keeps its own controls, including the one in another community. + expect(within(card).getAllByRole("button", { name: "Stop" })).toHaveLength(2); + expect( + within(card).getByText("Installed Buzz · Development Buzz"), + ).not.toBeVisible(); + fireEvent.click( + within(card).queryByText("Identity & sources") ?? + within(card).getByText("Identity & sources"), + ); + expect( + within(card).getByText("Installed Buzz · Development Buzz"), + ).toBeVisible(); + expect( + within( + screen.getByRole("region", { name: "Local agents in other communities" }), + ).getAllByRole("article"), + ).toHaveLength(1); + expect( + within( + screen.getByRole("region", { name: "Available to import" }), + ).getByRole("article", { name: "Agent Importable" }), + ).toBeVisible(); + expect( + within( + await screen.findByRole("region", { name: "Relay-only agents" }), + ).getByRole("article", { name: "Agent Not imported" }), + ).toBeVisible(); +}); diff --git a/src/bundled/agents/InventoryView.tsx b/src/bundled/agents/InventoryView.tsx index 453d9cd18..749226feb 100644 --- a/src/bundled/agents/InventoryView.tsx +++ b/src/bundled/agents/InventoryView.tsx @@ -29,6 +29,7 @@ export function InventoryView({ duplicate, remove, importedId, + onUseHere, children, }: { state: AgentControlState; @@ -42,6 +43,7 @@ export function InventoryView({ duplicate?: ((agent: AgentView) => void) | undefined; remove?: ((agent: AgentView) => void) | undefined; importedId: string | null; + onUseHere(pubkey: string): void; children?: ReactNode; }) { const data = state.data; @@ -77,10 +79,11 @@ export function InventoryView({

{group}

- {identities.map(({ row }) => ( + {identities.map(({ row, decision }) => ( ))}
diff --git a/src/bundled/agents/ManagedAgentActions.tsx b/src/bundled/agents/ManagedAgentActions.tsx index 7144e68b7..1c407377e 100644 --- a/src/bundled/agents/ManagedAgentActions.tsx +++ b/src/bundled/agents/ManagedAgentActions.tsx @@ -18,6 +18,7 @@ export function ManagedAgentActions({ imported, destination = "", owner = "", + onUseHere, }: { agent: AgentView; state: AgentControlState; @@ -25,6 +26,7 @@ export function ManagedAgentActions({ imported: boolean; destination?: string; owner?: string; + onUseHere?: ((pubkey: string) => void) | undefined; }) { const [settingUp, setSettingUp] = useState(false); const details = useRef(null); @@ -96,7 +98,14 @@ export function ManagedAgentActions({

)} {agent.configured === false && - (state.data?.localInventoryActions && control.configureHere ? ( + (onUseHere ? ( + + ) : state.data?.localInventoryActions && control.configureHere ? ( {}} importedId={null} + onUseHere={() => {}} /> ); const mounted = render(view()); diff --git a/src/bundled/agents/UnifiedInventory.tsx b/src/bundled/agents/UnifiedInventory.tsx index dbddac7cd..66ad26c66 100644 --- a/src/bundled/agents/UnifiedInventory.tsx +++ b/src/bundled/agents/UnifiedInventory.tsx @@ -21,6 +21,7 @@ export function UnifiedInventory({ duplicate, remove, importedId, + onUseHere, }: { state: AgentControlState; control: AgentControl; @@ -29,6 +30,7 @@ export function UnifiedInventory({ duplicate?: ((agent: AgentView) => void) | undefined; remove?: ((agent: AgentView) => void) | undefined; importedId: string | null; + onUseHere(pubkey: string): void; }) { const { agentLibrary: library, archives, profiles } = connection.session; const publicProfiles = useSyncExternalStore( @@ -101,6 +103,7 @@ export function UnifiedInventory({ duplicate={duplicate} remove={remove} importedId={importedId} + onUseHere={onUseHere} > {data.inventoryWarnings?.map((warning) => (

diff --git a/src/bundled/agents/inventory-decisions.ts b/src/bundled/agents/inventory-decisions.ts index 69081e69b..8df13cf06 100644 --- a/src/bundled/agents/inventory-decisions.ts +++ b/src/bundled/agents/inventory-decisions.ts @@ -30,5 +30,15 @@ export function inventoryDecision( : row.oldBuzzSources.length ? importGroup : relayGroup; - return { group }; + const action = + row.localIdentity && row.localSetups.size === 0 + ? destination + ? "use" + : "wait" + : "unavailable"; + const blocked = + action === "wait" + ? "Connect to a destination community to set up this identity." + : undefined; + return { group, action, blocked }; } From da5a34fbc9a6d0dffee7aa56a9a2da3edc49a386 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 27 Sep 2026 20:13:52 +0200 Subject: [PATCH 2/3] Position Use here confirmation with shared dialog styles Signed-off-by: klopez4212 --- src/bundled/agents/AgentControlPanel.tsx | 2 +- tests/browser/agent-use-here.spec.mjs | 47 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/browser/agent-use-here.spec.mjs diff --git a/src/bundled/agents/AgentControlPanel.tsx b/src/bundled/agents/AgentControlPanel.tsx index 87b57d025..2ecb526f3 100644 --- a/src/bundled/agents/AgentControlPanel.tsx +++ b/src/bundled/agents/AgentControlPanel.tsx @@ -273,7 +273,7 @@ export function AgentControlPanel({ Set up agent here diff --git a/tests/browser/agent-use-here.spec.mjs b/tests/browser/agent-use-here.spec.mjs new file mode 100644 index 000000000..41425b0a1 --- /dev/null +++ b/tests/browser/agent-use-here.spec.mjs @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; +import { createServer } from "./vite-server.mjs"; +import config from "../fixtures/agent-control.vite.mjs"; + +test("Use here opens a visible dialog at desktop and narrow widths", async ({ + page, +}) => { + const server = await createServer({ + ...config, + configFile: false, + logLevel: "error", + server: { host: "127.0.0.1", port: 0, strictPort: false }, + }); + await server.listen(); + try { + await page.goto( + `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/agent-control.html`, + ); + await page.evaluate(async () => { + const f = window.agentControlFixture; + f.data.parked = []; + Object.assign(f.agent, { + configured: false, + enabled: false, + status: "stopped", + runningRevision: null, + }); + await f.control.refresh(); + }); + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 800 }); + await page.getByRole("button", { name: "Use here", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Set up agent here" }); + await expect(dialog).toHaveCSS("position", "fixed"); + await expect(dialog).toBeInViewport({ ratio: 1 }); + await expect( + dialog.getByRole("button", { name: "Use here", exact: true }), + ).toBeInViewport({ ratio: 1 }); + const close = dialog.getByRole("button", { name: "Close", exact: true }); + await expect(close).toBeInViewport({ ratio: 1 }); + await close.click(); + await expect(dialog).toHaveCount(0); + } + } finally { + await server.close(); + } +}); From 0317113c864ff455fcd58ac5b7a6c5abc0b2db1e Mon Sep 17 00:00:00 2001 From: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Date: Tue, 29 Sep 2026 13:03:19 -0400 Subject: [PATCH 3/3] Hide Use here where the connection cannot confirm setup Use here asks the destination to confirm the agent with the owner's key. Only the development broker serves that confirmation. The packaged app's native adapter rejects it, so the action looked available but always failed. Gate the card button, the setup dialog and the imported-agent fallback on agentSetupConfirmationAvailable(), and explain why the action is missing. Test the real native adapter selection without mocking communityRequest. Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- src/bundled/agents/InventoryIdentityCard.tsx | 10 +- .../LocalInventoryAction.native.test.tsx | 150 ++++++++++++++++++ src/bundled/agents/LocalInventoryAction.tsx | 10 +- src/bundled/agents/ManagedAgentActions.tsx | 8 +- src/features/communities/api.ts | 6 + 5 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 src/bundled/agents/LocalInventoryAction.native.test.tsx diff --git a/src/bundled/agents/InventoryIdentityCard.tsx b/src/bundled/agents/InventoryIdentityCard.tsx index 387558176..cf0b5ba3a 100644 --- a/src/bundled/agents/InventoryIdentityCard.tsx +++ b/src/bundled/agents/InventoryIdentityCard.tsx @@ -8,6 +8,8 @@ import type { RelaySession } from "../../features/relay/session"; import type { Profile } from "../../features/relay/contracts"; import { Button } from "../../shared/design-system/ui/Button"; import { AgentCard } from "./AgentCard"; +import { agentSetupConfirmationAvailable } from "../../features/communities/api"; +import { agentSetupUnavailableMessage } from "./LocalInventoryAction"; import { ManagedAgentActions } from "./ManagedAgentActions"; import { localHereGroup, type inventoryDecision } from "./inventory-decisions"; import { type AgentInventoryIdentity, localSetups } from "./inventory-model"; @@ -44,6 +46,7 @@ export function InventoryIdentityCard({ if (!data) return null; const avatar = row.avatar ?? publicProfiles.get(row.pubkey)?.picture; const tile = decision.group === localHereGroup; + const setupAvailable = agentSetupConfirmationAvailable(); // The app runs every saved setup, so each keeps its controls whether or not // its community is the one currently selected or connected. const setups = localSetups(row, destination); @@ -88,17 +91,20 @@ export function InventoryIdentityCard({ state.status !== "ready" || !!decision.blocked || !data.localInventoryActions || - !control.configureHere + !control.configureHere || + !setupAvailable } onClick={() => onUseHere(row.pubkey)} > Use here {decision.blocked &&

{decision.blocked}

} - {!data.localInventoryActions && ( + {!data.localInventoryActions ? (

Restart an updated desktop build to use local inventory actions.

+ ) : ( + !setupAvailable &&

{agentSetupUnavailableMessage}

)} )} diff --git a/src/bundled/agents/LocalInventoryAction.native.test.tsx b/src/bundled/agents/LocalInventoryAction.native.test.tsx new file mode 100644 index 000000000..42276e3e5 --- /dev/null +++ b/src/bundled/agents/LocalInventoryAction.native.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment jsdom +import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing"; +stubAvatarBrowserApis(); +import "@testing-library/jest-dom/vitest"; +import { invoke } from "@tauri-apps/api/core"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import { + agentSetupConfirmationAvailable, + communityRequest, +} from "../../features/communities/api"; +import { createAgentControl } from "../../features/agents/control"; +import { controlFixture } from "../../features/agents/control-testing"; +import { createRelaySession } from "../../features/relay/session"; +import { inventoryIdentities } from "./inventory-model"; +import { InventoryView } from "./InventoryView"; +import { + agentSetupUnavailableMessage, + LocalInventoryAction, +} from "./LocalInventoryAction"; +import { ManagedAgentActions } from "./ManagedAgentActions"; + +// A packaged desktop connection: the real adapter selection, with no broker. +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(async (command: string) => { + throw new Error(`Unexpected native command ${command}`); + }), + isTauri: () => true, +})); +const destination = "https://relay.example.test"; +const owner = "de".repeat(32); +const disposals: (() => void)[] = []; +beforeEach(() => { + vi.stubGlobal("navigator", { ...navigator, platform: "MacIntel" }); + vi.stubEnv("VITE_BUZZ_LIVE", "0"); + vi.stubGlobal( + "fetch", + vi.fn(() => { + throw new Error("No development broker in this build"); + }), + ); +}); +afterEach(() => { + cleanup(); + for (const dispose of disposals.splice(0)) dispose(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.mocked(invoke).mockClear(); +}); + +function incompleteImport() { + const f = controlFixture(); + Object.assign(f.agent, { + configured: false, + enabled: false, + status: "stopped", + runningRevision: null, + }); + const control = createAgentControl(f.host); + disposals.push(() => control.dispose()); + return { f, control }; +} + +it("matches the packaged adapter, which cannot confirm agent setup", async () => { + expect(agentSetupConfirmationAvailable()).toBe(false); + await expect( + communityRequest(destination, "resolve-agent-community", { + pubkey: "ab".repeat(32), + owner, + confirmed: true, + }), + ).rejects.toThrow("This operation is unavailable on the packaged connection"); + expect(invoke).not.toHaveBeenCalled(); +}); + +it("explains the missing confirmation instead of offering Use here in the dialog", async () => { + const { f, control } = incompleteImport(); + await control.refresh(); + render( + {}} + onUsed={() => {}} + />, + ); + expect(screen.getByRole("status")).toHaveTextContent( + agentSetupUnavailableMessage, + ); + expect(screen.queryByRole("button", { name: "Use here" })).toBeNull(); +}); + +it("does not offer Use here for an imported identity that needs setup", async () => { + const { f, control } = incompleteImport(); + await control.refresh(); + const onUseHere = vi.fn(); + render( + , + ); + expect(screen.queryByRole("button", { name: "Use here" })).toBeNull(); + expect(screen.getByText(agentSetupUnavailableMessage)).toBeVisible(); + expect( + screen.queryByText(/Choose Use here to set up this identity/), + ).toBeNull(); +}); + +it("disables the inventory card's Use here and says why", async () => { + const { f, control } = incompleteImport(); + await control.refresh(); + const owned = createRelaySession({ + viewer: owner, + relayAuthor: "ef".repeat(32), + scope: "wss://relay.example.test", + query: async () => [], + media: () => undefined, + }); + disposals.push(() => owned.dispose()); + const onUseHere = vi.fn(); + render( + fallback)} + profiles={[]} + publicProfiles={new Map()} + edit={() => {}} + importedId={null} + onUseHere={onUseHere} + />, + ); + const card = await screen.findByRole("article", { + name: "Agent Fixture agent", + }); + expect(within(card).getByRole("button", { name: "Use here" })).toBeDisabled(); + expect(within(card).getByText(agentSetupUnavailableMessage)).toBeVisible(); + expect(onUseHere).not.toHaveBeenCalled(); +}); diff --git a/src/bundled/agents/LocalInventoryAction.tsx b/src/bundled/agents/LocalInventoryAction.tsx index 1212acc04..76e645b4a 100644 --- a/src/bundled/agents/LocalInventoryAction.tsx +++ b/src/bundled/agents/LocalInventoryAction.tsx @@ -4,9 +4,15 @@ import type { AgentView, CommunityResolution, } from "../../features/agents/control"; -import { communityRequest } from "../../features/communities/api"; +import { + agentSetupConfirmationAvailable, + communityRequest, +} from "../../features/communities/api"; import { Button } from "../../shared/design-system/ui/Button"; +export const agentSetupUnavailableMessage = + "This app version cannot finish setting up imported agents yet."; + /** Uses retained app custody, never a legacy preview or credential import. */ export function LocalInventoryAction({ control, @@ -35,6 +41,8 @@ export function LocalInventoryAction({ }, [onPending]); const [pending, setPending] = useState(false); const [error, setError] = useState(null); + if (!agentSetupConfirmationAvailable()) + return

{agentSetupUnavailableMessage}

; return ( <>

diff --git a/src/bundled/agents/ManagedAgentActions.tsx b/src/bundled/agents/ManagedAgentActions.tsx index 1c407377e..de74847e8 100644 --- a/src/bundled/agents/ManagedAgentActions.tsx +++ b/src/bundled/agents/ManagedAgentActions.tsx @@ -7,6 +7,7 @@ import { type AgentControlState, type AgentView, } from "../../features/agents/control"; +import { agentSetupConfirmationAvailable } from "../../features/communities/api"; import { LocalInventoryAction } from "./LocalInventoryAction"; import { Button } from "../../shared/design-system/ui/Button"; import { agentProcessLabel } from "./agent-edit"; @@ -29,6 +30,7 @@ export function ManagedAgentActions({ onUseHere?: ((pubkey: string) => void) | undefined; }) { const [settingUp, setSettingUp] = useState(false); + const setupAvailable = agentSetupConfirmationAvailable(); const details = useRef(null); const [checking, setChecking] = useState(false); // Describes one refreshed status; any later status change supersedes it. @@ -93,12 +95,14 @@ export function ManagedAgentActions({

Imported, not started.{" "} {agent.configured === false - ? "Choose Use here to set up this identity in a community." + ? setupAvailable + ? "Choose Use here to set up this identity in a community." + : "It is not set up in a community yet." : "Start it when you are ready."}

)} {agent.configured === false && - (onUseHere ? ( + (onUseHere && setupAvailable ? (