diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 6a38ead880..b181c3f5d1 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -294,7 +294,11 @@ agent-record migrations; blank per-agent values inherit them. A plugin, bundled or installed, can add a tab to the settings of every agent running on this device, whatever its type, with `ctx.agents.registerTab({ id, title, component })`. The component receives -`{ agent }`; it reads and saves through the plugin's own `ctx.agents`. The tab +`{ agent }`; it reads and saves through the plugin's own `ctx.agents`. An +optional `when(agent)` limits the tab to the agents it returns true for, and an +optional `subscribe(listener)` tells the host when `when` may answer +differently, so the tab appears or goes without a reload. A `when` that throws +hides only its own tab. The tab follows Activity and precedes History and Settings, in the agent's Build view and its profile panel, and is not offered for hosted or elsewhere agents. It is removed when the plugin is turned off or its scope ends. The bundled Agent diff --git a/src/app/entity-navigation.test.tsx b/src/app/entity-navigation.test.tsx index 409493dcaa..beb99fce86 100644 --- a/src/app/entity-navigation.test.tsx +++ b/src/app/entity-navigation.test.tsx @@ -683,21 +683,23 @@ it("lists active primary pages in their sidebar and header placements", async () ); // The Attention tab is on by default, and attention keeps running without it. + // Bestie adds its own tab beside it. const providers = () => current.agents.attentionProviders().map(({ provider }) => provider); + const tabs = () => + current.agents + .addedTabs() + .map((tab) => tab.key) + .sort(); await waitFor(() => - expect(current.agents.addedTabs().map((tab) => tab.key)).toEqual([ - "buzz.watching/watching", - ]), + expect(tabs()).toEqual(["buzz.bestie/bestie", "buzz.watching/watching"]), ); await act(() => current.plugins.change("disable", "buzz.watching")); - await waitFor(() => expect(current.agents.addedTabs()).toEqual([])); + await waitFor(() => expect(tabs()).toEqual(["buzz.bestie/bestie"])); expect(providers()).toContain("attention/command"); await act(() => current.plugins.change("enable", "buzz.watching")); await waitFor(() => - expect(current.agents.addedTabs().map((tab) => tab.key)).toEqual([ - "buzz.watching/watching", - ]), + expect(tabs()).toEqual(["buzz.bestie/bestie", "buzz.watching/watching"]), ); await act(() => current.plugins.change("disable", "buzz.agents")); diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index 89ba2a3819..3ca0db630a 100644 --- a/src/bundled/agents/AgentsPage.tsx +++ b/src/bundled/agents/AgentsPage.tsx @@ -55,19 +55,13 @@ import type { TeamPort, } from "../../features/agents/service"; import { useRelayConnection } from "../../features/relay/react"; -import { useAgentChoices } from "../../features/agents/use-choices"; -import type { AgentChoicesSnapshot } from "../../features/agents/choices"; import type { RelaySession } from "../../features/relay/session"; import { readSnapshot, type SharedDefinition, } from "../../features/agents/agent-share"; -import { - chooseDefaultAgent, - restoreDefaultAgent, - useDefaultAgent, - useSavedAgent, -} from "../../features/solo/default-agent"; +import { useDefaultAgent } from "../../features/solo/default-agent"; +import { useBestieChoice } from "./BestieChoice"; import type { RelayData } from "../../features/relay/service"; import type { Navigation } from "../../features/navigation/controller"; import { targetKey } from "../../features/navigation/targets"; @@ -84,7 +78,6 @@ import { XIcon, } from "../../shared/design-system/icons"; import { Button } from "../../shared/design-system/ui/Button"; -import { AlertDialog } from "../../shared/design-system/ui/AlertDialog"; import { Avatar } from "../../shared/design-system/ui/Avatar"; import { Checkbox } from "../../shared/design-system/ui/Checkbox"; import { ChoiceRow } from "../../shared/design-system/ui/ChoiceRow"; @@ -738,8 +731,6 @@ type InventoryProps = { onViewListing?: ((agent: SharedAgent) => void) | undefined; }; -type BestieAgent = AgentChoicesSnapshot["selectable"][number]; - /** Agents keeps Morgan's account-synced selection owner; this surface only * names an explicitly saved selection Bestie and asks before replacing it. */ function DefaultAgentInventory({ @@ -750,125 +741,23 @@ function DefaultAgentInventory({ // Keep Morgan's automatic fallback/adoption behavior active even though the // Bestie pill names only the explicit account-saved choice. useDefaultAgent(session, scope); - const saved = useSavedAgent(session, scope); - const choices = useAgentChoices(session, true, true); - const [error, setError] = useState(""); - const [savingBestie, setSavingBestie] = useState(false); - const [replacement, setReplacement] = useState<{ - current: BestieAgent; - replacing: BestieAgent; - }>(); - // Only the latest choice reports its failure. - const latest = useRef(0); - const eligible = (pubkey: string) => - session.agentChoices - .snapshot() - .selectable.find((agent) => agent.pubkey === pubkey); - const save = (pubkey: string) => { - if (!eligible(pubkey)) return; - const choice = ++latest.current; - setError(""); - setSavingBestie(true); - chooseDefaultAgent(session, scope, pubkey) - .then(() => { - if (choice === latest.current) setReplacement(undefined); - }) - .catch((failure: Error) => { - if (choice === latest.current) setError(failure.message); - }) - .finally(() => { - if (choice === latest.current) setSavingBestie(false); - }); - }; + const bestie = useBestieChoice(session, scope); return ( <> agent.pubkey)) + new Set(bestie.choices.selectable.map((agent) => agent.pubkey)) } - onMakeBestie={(pubkey) => { - const replacing = eligible(pubkey); - if (!replacing || pubkey === saved) return; - const previous = choices.identities.find( - (agent) => agent.pubkey === saved, - ); - if (previous) setReplacement({ current: previous, replacing }); - else save(pubkey); - }} + onMakeBestie={bestie.make} /> - {replacement && ( - { - if (error) { - restoreDefaultAgent( - scope, - replacement.replacing.pubkey, - replacement.current.pubkey, - ); - setError(""); - } - setReplacement(undefined); - }} - onChoose={() => { - // A choice changed elsewhere while this dialog was open wins. - if (saved === replacement.current.pubkey) - save(replacement.replacing.pubkey); - else setReplacement(undefined); - }} - /> - )} - {error && !replacement &&

{error}

} + {bestie.view} ); } -const bestieName = (agent: BestieAgent) => - agent.managedName ?? agent.name ?? "Agent"; - -function BestieReplacement({ - current, - replacing, - pending, - error, - onChoose, - onClose, -}: { - current: BestieAgent; - replacing: BestieAgent; - pending: boolean; - error: string; - onChoose(): void; - onClose(): void; -}) { - const keep = useRef(null); - return ( - {error}

})} - actions={ - <> - - - - } - /> - ); -} - /** Right-clicking an agent card opens its actions menu; the context-menu key * and Shift+F10 open it from the keyboard. */ function CardContextMenu({ diff --git a/src/bundled/agents/BestieChoice.tsx b/src/bundled/agents/BestieChoice.tsx new file mode 100644 index 0000000000..484e7bca46 --- /dev/null +++ b/src/bundled/agents/BestieChoice.tsx @@ -0,0 +1,127 @@ +import { useRef, useState } from "react"; +import { useAgentChoices } from "../../features/agents/use-choices"; +import type { AgentChoicesSnapshot } from "../../features/agents/choices"; +import type { RelaySession } from "../../features/relay/session"; +import { + chooseDefaultAgent, + restoreDefaultAgent, + useSavedAgent, +} from "../../features/solo/default-agent"; +import { AlertDialog } from "../../shared/design-system/ui/AlertDialog"; +import { Button } from "../../shared/design-system/ui/Button"; + +type BestieAgent = AgentChoicesSnapshot["selectable"][number]; + +/** Making an agent Bestie, wherever it is offered: `make` asks before + * replacing an explicitly saved Bestie, in the dialog `view` renders. */ +export function useBestieChoice(session: RelaySession, scope: string) { + // Bestie names only the explicit account-saved choice. + const saved = useSavedAgent(session, scope); + const choices = useAgentChoices(session, true, true); + const [error, setError] = useState(""); + const [savingBestie, setSavingBestie] = useState(false); + const [replacement, setReplacement] = useState<{ + current: BestieAgent; + replacing: BestieAgent; + }>(); + // Only the latest choice reports its failure. + const latest = useRef(0); + const eligible = (pubkey: string) => + session.agentChoices + .snapshot() + .selectable.find((agent) => agent.pubkey === pubkey); + const save = (pubkey: string) => { + if (!eligible(pubkey)) return; + const choice = ++latest.current; + setError(""); + setSavingBestie(true); + chooseDefaultAgent(session, scope, pubkey) + .then(() => { + if (choice === latest.current) setReplacement(undefined); + }) + .catch((failure: Error) => { + if (choice === latest.current) setError(failure.message); + }) + .finally(() => { + if (choice === latest.current) setSavingBestie(false); + }); + }; + const make = (pubkey: string) => { + const replacing = eligible(pubkey); + if (!replacing || pubkey === saved) return; + const previous = choices.identities.find((agent) => agent.pubkey === saved); + if (previous) setReplacement({ current: previous, replacing }); + else save(pubkey); + }; + const view = ( + <> + {replacement && ( + { + if (error) { + restoreDefaultAgent( + scope, + replacement.replacing.pubkey, + replacement.current.pubkey, + ); + setError(""); + } + setReplacement(undefined); + }} + onChoose={() => { + // A choice changed elsewhere while this dialog was open wins. + if (saved === replacement.current.pubkey) + save(replacement.replacing.pubkey); + else setReplacement(undefined); + }} + /> + )} + {error && !replacement &&

{error}

} + + ); + return { saved, choices, make, view }; +} + +export const bestieName = (agent: BestieAgent) => + agent.managedName ?? agent.name ?? "Agent"; + +function BestieReplacement({ + current, + replacing, + pending, + error, + onChoose, + onClose, +}: { + current: BestieAgent; + replacing: BestieAgent; + pending: boolean; + error: string; + onChoose(): void; + onClose(): void; +}) { + const keep = useRef(null); + return ( + {error}

})} + actions={ + <> + + + + } + /> + ); +} diff --git a/src/bundled/bestie/BestieTab.test.tsx b/src/bundled/bestie/BestieTab.test.tsx new file mode 100644 index 0000000000..a6b57b29b1 --- /dev/null +++ b/src/bundled/bestie/BestieTab.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import type { Agent } from "../../features/agents/service"; +import type { RelayData } from "../../features/relay/service"; +import { writeView } from "../../shared/view-state"; +import { bestieTab } from "./BestieTab"; + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +const OWNER = "f".repeat(64); +const SCOPE = `https://relay.example:${OWNER}`; +const member = (name: string, digit: string): Agent => + ({ + key: digit.repeat(64), + pubkey: digit.repeat(64), + name, + type: "test/echo", + owner: OWNER, + relay: "wss://relay.example", + place: "here", + }) as Agent; +const ada = member("Ada", "a"); +const bea = member("Bea", "b"); + +function community(saved: string | undefined, agents = [ada, bea]) { + let record = saved; + const choice = (agent: Agent) => ({ + pubkey: agent.pubkey, + name: agent.name, + managed: true, + }); + const choices = { + status: "ready", + complete: true, + pending: false, + identities: agents.map(choice), + selectable: agents.map(choice), + archives: { status: "unavailable", archived: [] }, + }; + const mainAgent = { + read: vi.fn(async () => record), + write: vi.fn(async (agent: string) => { + record = agent; + }), + }; + const connection = { + status: "ready", + scope: SCOPE, + session: { + mainAgent, + agentChoices: { + snapshot: () => choices, + subscribe: () => () => {}, + retain: () => () => {}, + }, + }, + }; + let current: unknown = connection; + const relayListeners = new Set<() => void>(); + const relay = { + snapshot: () => current, + subscribe: (listener: () => void) => { + relayListeners.add(listener); + return () => void relayListeners.delete(listener); + }, + } as unknown as RelayData; + /** Replaces the session, as a reconnection does. */ + const reconnect = (scope: string) => { + current = { + ...connection, + scope, + session: { ...connection.session, mainAgent: { ...mainAgent } }, + }; + for (const listener of relayListeners) listener(); + }; + return { relay, mainAgent, reconnect }; +} + +it("shows on the saved Bestie only, and moves when Change picks another", async () => { + const user = userEvent.setup(); + const { relay, mainAgent } = community(ada.pubkey); + const tab = bestieTab(relay); + const changed = vi.fn(); + const stop = tab.subscribe?.(changed); + // Nothing shows before the account's choice is read. + expect(tab.when?.(ada)).toBe(false); + await vi.waitFor(() => expect(tab.when?.(ada)).toBe(true)); + expect(tab.when?.(bea)).toBe(false); + // The same agent in another community is not this one's Bestie. + expect(tab.when?.({ ...ada, relay: "wss://other.example" })).toBe(false); + + const Tab = tab.component; + render(); + expect(screen.getByText("This is your Bestie")).toBeVisible(); + expect(screen.getByText("Remembers you")).toBeVisible(); + // No switches yet. + expect(screen.queryByRole("switch")).toBeNull(); + changed.mockClear(); + await user.click(screen.getByRole("button", { name: "Change" })); + // Change offers every other agent you can make Bestie. + expect(screen.queryByRole("menuitem", { name: "Ada" })).toBeNull(); + await user.click(await screen.findByRole("menuitem", { name: "Bea" })); + const confirmation = await screen.findByRole("alertdialog", { + name: "Make Bea your bestie?", + }); + await user.click( + within(confirmation).getByRole("button", { name: "Switch to Bea" }), + ); + expect(mainAgent.write).toHaveBeenCalledWith(bea.pubkey); + await vi.waitFor(() => expect(tab.when?.(bea)).toBe(true)); + expect(tab.when?.(ada)).toBe(false); + // The host is told, so the tab moves without a reload. + expect(changed).toHaveBeenCalled(); + stop?.(); +}); + +it("keeps Change unavailable while there is no other agent to choose", async () => { + const { relay } = community(ada.pubkey, [ada]); + const Tab = bestieTab(relay).component; + await act(async () => { + render(); + }); + expect(screen.getByRole("button", { name: "Change" })).toBeDisabled(); +}); + +it("tells the host only when the Bestie or its community changes", async () => { + const { relay, reconnect } = community(ada.pubkey); + const tab = bestieTab(relay); + const changed = vi.fn(); + const stop = tab.subscribe?.(changed); + await vi.waitFor(() => expect(tab.when?.(ada)).toBe(true)); + changed.mockClear(); + // Other view state in the community, such as a draft, isn't a change. + writeView(SCOPE, "composer:draft", "hello"); + expect(changed).not.toHaveBeenCalled(); + // A new session in another community is. + reconnect(`https://other.example:${OWNER}`); + expect(changed).toHaveBeenCalledTimes(1); + expect(tab.when?.(ada)).toBe(false); + stop?.(); +}); diff --git a/src/bundled/bestie/BestieTab.tsx b/src/bundled/bestie/BestieTab.tsx new file mode 100644 index 0000000000..c1d3fafe50 --- /dev/null +++ b/src/bundled/bestie/BestieTab.tsx @@ -0,0 +1,147 @@ +import type { Agent, AgentAddedTab } from "../../features/agents/service"; +import { relayOrigin } from "../../features/communities/destination"; +import { relayPartition } from "../../features/relay/partition"; +import { useRelayConnection } from "../../features/relay/react"; +import type { RelayData, RelaySnapshot } from "../../features/relay/service"; +import { savedAgentStore } from "../../features/solo/default-agent"; +import { BestieIcon } from "../../shared/design-system/icons"; +import { Button } from "../../shared/design-system/ui/Button"; +import { InlineHeader } from "../../shared/design-system/ui/Header"; +import { + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuRoot, + MenuTrigger, +} from "../../shared/design-system/ui/Menu"; +import { PreferenceRow } from "../../shared/design-system/ui/PreferenceRow"; +import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup"; +import { bestieName, useBestieChoice } from "../agents/BestieChoice"; + +/** The Bestie tab, on the agent saved as Bestie in its community. */ +export function bestieTab(relay: RelayData): AgentAddedTab { + const bestie = savedBestie(relay); + return { + id: "bestie", + title: "Bestie", + when: (agent) => + agent.pubkey === bestie.snapshot() && + inCommunity(agent, relay.snapshot()), + subscribe: bestie.subscribe, + component: ({ agent }) => , + }; +} + +/** The saved Bestie in the connected community, across reconnections. */ +function savedBestie(relay: RelayData) { + let current: + | Readonly<{ + snapshot: RelaySnapshot; + store: ReturnType; + }> + | undefined; + const store = () => { + const snapshot = relay.snapshot(); + if ( + current?.snapshot.session !== snapshot.session || + current.snapshot.scope !== snapshot.scope + ) + current = { + snapshot, + store: savedAgentStore(snapshot.session, snapshot.scope ?? ""), + }; + return current.store; + }; + return { + snapshot: () => store().snapshot(), + subscribe(listener: () => void) { + // The saved choice's sources also change for unrelated view state, so + // only a different Bestie, or community, is passed on. + let following = store(); + let last = following.snapshot(); + const changed = () => { + const next = following.snapshot(); + if (next === last) return; + last = next; + listener(); + }; + let stop = following.subscribe(changed); + const stopRelay = relay.subscribe(() => { + if (store() === following) return; + stop(); + following = store(); + last = following.snapshot(); + stop = following.subscribe(changed); + listener(); + }); + return () => { + stopRelay(); + stop(); + }; + }, + }; +} + +function inCommunity(agent: Agent, { scope }: RelaySnapshot) { + try { + return relayPartition(relayOrigin(agent.relay), agent.owner) === scope; + } catch { + return false; + } +} + +function BestieTab({ relay, agent }: { relay: RelayData; agent: Agent }) { + const { session, scope } = useRelayConnection(relay); + const bestie = useBestieChoice(session, scope ?? ""); + const others = bestie.choices.selectable.filter( + (choice) => choice.pubkey !== agent.pubkey, + ); + return ( + // Its own size container: rows wrap their buttons to the panel's width, + // as each agent type's settings form does, and nothing widens the tab. +
+ + } + title="This is your Bestie" + subtitle="It's pinned at the top of Buzz, with your DM as its home." + trailing={ + + Change} + /> + + + Make your Bestie + {others.map((choice) => ( + bestie.make(choice.pubkey)} + > + {bestieName(choice)} + + ))} + + + + } + /> + + + + + + + + {bestie.view} +
+ ); +} diff --git a/src/bundled/bestie/README.md b/src/bundled/bestie/README.md index 171a0fea47..835ad71a30 100644 --- a/src/bundled/bestie/README.md +++ b/src/bundled/bestie/README.md @@ -10,6 +10,13 @@ Open **Bestie** and choose **Set up Bestie**. Setup signs you in to Builderlab, creates your hosted Bestie, gives it its instructions, creates your private channel and sends a first message on your behalf so Bestie introduces itself. +### The Bestie tab + +The agent you made your Bestie (with **Make my bestie** in Agents) gets a +**Bestie** tab on its agent page while it runs on this device. **Change** picks +another agent and asks before replacing your Bestie. The tab lists Bestie's +settings, which are not set up yet. + --- ## Changing Bestie's instructions diff --git a/src/bundled/bestie/index.tsx b/src/bundled/bestie/index.tsx index 497f0857b8..4411004dbd 100644 --- a/src/bundled/bestie/index.tsx +++ b/src/bundled/bestie/index.tsx @@ -29,6 +29,7 @@ import { type RemoteBestie, } from "./setup"; import type { RemoteAgentModel } from "../builderlab/agents/client"; +import { bestieTab } from "./BestieTab"; export const inject = [ "pages", @@ -66,6 +67,7 @@ export const apply: PluginModule["apply"] = (ctx) => { }); }); ctx.effect(() => () => listeners.clear()); + ctx.agents.registerTab(bestieTab(ctx.relay)); ctx.pages.register({ id: "bestie", title: "Bestie", diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 7a937634ad..5bb414ef08 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -8458,6 +8458,44 @@ it("adds a plugin's tab to every agent's settings until the plugin goes", async expect(service.addedTabs()).toEqual([]); }); +it("renders a tab's views again when its `when` may answer differently", async () => { + const { service, ctx } = await setup(); + const owner = ctx.extend({ + pluginOwner: Object.freeze({ id: "buzz.bestie", revision: "bundled" }), + }); + const listeners = new Set<() => void>(); + const tab = { + id: "bestie", + title: "Bestie", + component: () => null, + when: () => true, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => void listeners.delete(listener); + }, + }; + const plugin = owner.plugin({ + inject: ["agents"], + apply: (child: Context) => child.agents.registerTab(tab), + }); + await plugin; + expect(listeners.size).toBe(1); + const before = service.snapshot(); + const listener = vi.fn(); + service.subscribe(listener); + for (const changed of listeners) changed(); + expect(service.snapshot()).not.toBe(before); + expect(listener).toHaveBeenCalled(); + for (const bad of [ + { ...tab, id: "other", when: true as never }, + { ...tab, id: "other", subscribe: "soon" as never }, + ]) + expect(() => owner.agents.registerTab(bad)).toThrow(/Agent tabs need/); + // The host stops listening when the plugin goes. + await plugin.dispose(); + expect(listeners.size).toBe(0); +}); + it("refuses a hosted type that also runs here", async () => { const { plugin } = await setup(); const { type } = cloud(); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 87d3802334..cacd51ea4d 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -295,12 +295,16 @@ export type AgentTab = Readonly<{ component: ComponentType>; }>; /** A tab a plugin adds to the settings of every agent running on this device, - * whatever its type. It reads the agent and saves through the plugin's own - * `agents`. */ + * whatever its type, or only those `when` accepts. It reads the agent and + * saves through the plugin's own `agents`. */ export type AgentAddedTab = Readonly<{ id: string; title: string; component: ComponentType<{ agent: Agent }>; + /** Whether the tab shows for this agent; absent, it shows for each. */ + when?: (agent: Agent) => boolean; + /** Calls `listener` when `when` may answer differently. */ + subscribe?: (listener: () => void) => () => void; }>; export type RegisteredAgentAddedTab = Contribution; /** One of an agent's conversation sessions on this device. Without `root` it @@ -1005,7 +1009,8 @@ export type Agents = { register(type: AgentType): void; types(): readonly RegisteredAgentType[]; /** Adds a tab to the settings of every agent running on this device, - * whatever its type, after Activity. It shows while the plugin is on. */ + * whatever its type, or of those its `when` accepts, after Activity. It + * shows while the plugin is on. */ registerTab(tab: AgentAddedTab): void; /** The tabs active plugins add now. */ addedTabs(): readonly RegisteredAgentAddedTab[]; @@ -1958,10 +1963,21 @@ export class AgentsService extends Service implements Agents { if ( !/^[a-z0-9][a-z0-9._-]*$/.test(tab.id ?? "") || !tab.title || - typeof tab.component !== "function" + typeof tab.component !== "function" || + (tab.when !== undefined && typeof tab.when !== "function") || + (tab.subscribe !== undefined && typeof tab.subscribe !== "function") ) throw new Error("Agent tabs need an id, a title and a component"); this.tabContributions.register(this.ctx, tab); + // A new state object, so views asking `when` render again. + const { subscribe } = tab; + if (subscribe) + this.ctx.effect(() => + subscribe(() => { + this.state = Object.freeze({ ...this.state }); + this.notify(); + }), + ); } addedTabs = () => this.tabContributions.snapshot(); controlAttention() { diff --git a/src/features/agents/tabs.test.tsx b/src/features/agents/tabs.test.tsx index 7d4724f96a..3d1682768b 100644 --- a/src/features/agents/tabs.test.tsx +++ b/src/features/agents/tabs.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, expect, it } from "vitest"; +import { afterEach, expect, it, vi } from "vitest"; import type { Agent, Agents } from "./service"; import { agentSections } from "./tabs"; @@ -167,3 +167,42 @@ it("adds plugin tabs to local agents only, before History", () => { ); expect(screen.getByText("Watching Ada")).toBeInTheDocument(); }); + +it("adds a plugin tab only to the agents its `when` accepts", () => { + const tab = (id: string, when?: (agent: Agent) => boolean) => ({ + id, + key: `example/${id}`, + pluginId: "example", + revision: "1", + title: id, + component: () => null, + ...(when && { when }), + }); + const agents = { + configHistory: () => undefined, + addedTabs: () => [ + tab("every"), + tab("ada", (shown) => shown.name === "Ada"), + tab("broken", () => { + throw new Error("broken"); + }), + ], + } as unknown as Agents; + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const values = (name: string) => + agentSections({ + agents, + agent: { ...agent, name }, + type: undefined, + memories: undefined, + }).map((section) => section.value); + expect(values("Ada")).toEqual([ + "agent:added:example/every", + "agent:added:example/ada", + "agent:memory", + ]); + // A tab whose `when` throws hides only itself. + expect(values("Bea")).toEqual(["agent:added:example/every", "agent:memory"]); + expect(error).toHaveBeenCalled(); + error.mockRestore(); +}); diff --git a/src/features/agents/tabs.tsx b/src/features/agents/tabs.tsx index 383146c700..1f817246f5 100644 --- a/src/features/agents/tabs.tsx +++ b/src/features/agents/tabs.tsx @@ -97,12 +97,13 @@ export function agentSections({ render: () => , }); if (agent.place === "here") - for (const { key, title, component: Component } of agents.addedTabs()) - tabs.push({ - value: `agent:added:${key}`, - label: title, - render: () => , - }); + for (const { key, title, component: Component, when } of agents.addedTabs()) + if (!when || shows(when, agent, key)) + tabs.push({ + value: `agent:added:${key}`, + label: title, + render: () => , + }); if (agent.place === "here" && agents.configHistory(agent.pubkey)) tabs.push({ value: "agent:history", @@ -126,6 +127,16 @@ export function agentSections({ ]; } +/** A plugin's `when`; one that throws hides only its own tab. */ +function shows(when: (agent: Agent) => boolean, agent: Agent, key: string) { + try { + return when(agent); + } catch (error) { + console.error(`Agent tab ${key} could not decide whether to show`, error); + return false; + } +} + function HostSettings({ host }: { host: AgentSettingsHost }) { const form = useId(); const [saving, setSaving] = useState(false); diff --git a/src/features/solo/default-agent.ts b/src/features/solo/default-agent.ts index 3a0fddb74e..1b5d350a46 100644 --- a/src/features/solo/default-agent.ts +++ b/src/features/solo/default-agent.ts @@ -133,6 +133,29 @@ export function useSavedAgent(session: RelaySession, scope: string) { return session.mainAgent ? account.agent : local; } +/** `useSavedAgent` outside React. Subscribing starts the account's + * once-per-session read, as the hook does when it mounts. */ +export function savedAgentStore(session: RelaySession, scope: string) { + const account = session.mainAgent; + return Object.freeze({ + snapshot: () => (account ? saved.get(account) : chosenAgent(scope)), + subscribe(listener: () => void) { + if (account) void adopt(account, scope); + // The device's choice is browser storage; without a window there is none + // to follow. + const view = + typeof window === "undefined" + ? () => {} + : subscribeView(scope, listener); + const choices = subscribeSaved(listener); + return () => { + view(); + choices(); + }; + }, + }); +} + function chosenAgent(scope: string) { const value = unsaved.has(scope) ? unsaved.get(scope)