Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 9 additions & 7 deletions src/app/entity-navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
125 changes: 7 additions & 118 deletions src/bundled/agents/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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({
Expand All @@ -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 (
<>
<Inventory
{...props}
session={session}
bestie={saved}
bestie={bestie.saved}
defaultChoices={
new Set(choices.selectable.map((agent) => 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 && (
<BestieReplacement
{...replacement}
pending={savingBestie}
error={error}
onClose={() => {
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 && <p role="alert">{error}</p>}
{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<HTMLButtonElement>(null);
return (
<AlertDialog
title={`Make ${bestieName(replacing)} your bestie?`}
description={`Your existing chats with ${bestieName(current)} will still be available in your DMs.`}
initialFocus={keep}
pending={pending}
onClose={onClose}
{...(error && { children: <p role="alert">{error}</p> })}
actions={
<>
<Button ref={keep} disabled={pending} onClick={onClose}>
Keep {bestieName(current)}
</Button>
<Button variant="prominent" loading={pending} onClick={onChoose}>
Switch to {bestieName(replacing)}
</Button>
</>
}
/>
);
}

/** Right-clicking an agent card opens its actions menu; the context-menu key
* and Shift+F10 open it from the keyboard. */
function CardContextMenu({
Expand Down
127 changes: 127 additions & 0 deletions src/bundled/agents/BestieChoice.tsx
Original file line number Diff line number Diff line change
@@ -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 && (
<BestieReplacement
{...replacement}
pending={savingBestie}
error={error}
onClose={() => {
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 && <p role="alert">{error}</p>}
</>
);
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<HTMLButtonElement>(null);
return (
<AlertDialog
title={`Make ${bestieName(replacing)} your bestie?`}
description={`Your existing chats with ${bestieName(current)} will still be available in your DMs.`}
initialFocus={keep}
pending={pending}
onClose={onClose}
{...(error && { children: <p role="alert">{error}</p> })}
actions={
<>
<Button ref={keep} disabled={pending} onClick={onClose}>
Keep {bestieName(current)}
</Button>
<Button variant="prominent" loading={pending} onClick={onChoose}>
Switch to {bestieName(replacing)}
</Button>
</>
}
/>
);
}
Loading
Loading