})}
- 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.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)