Skip to content
Merged
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
111 changes: 111 additions & 0 deletions src/bundled/agents/AgentControlPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof controlFixture>) => void,
) {
const f = controlFixture();
configure(f);
const control = createAgentControl(f.host);
disposals.push(() => control.dispose());
render(
<AgentControlPanel
control={control}
importDestination="https://relay.example.test"
createOwner={"de".repeat(32)}
>
{(state, _edit, _duplicate, _remove, _importedId, _label, onUseHere) =>
state.status === "ready" && (
<>
<article aria-label="Agent Fixture agent">
<button type="button" onClick={() => onUseHere("ab".repeat(32))}>
Use here
</button>
</article>
</>
)
}
</AgentControlPanel>,
);
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);
});
79 changes: 77 additions & 2 deletions src/bundled/agents/AgentControlPanel.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<{
Expand All @@ -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<string[]>([]);
const [importedId, setImportedId] = useState<string | null>(null);
const [selected, setSelected] = useState<{
Expand All @@ -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(
Expand All @@ -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 =
Expand Down Expand Up @@ -141,6 +169,7 @@ export function AgentControlPanel({
<Button
variant="primary"
aria-haspopup="dialog"
disabled={localPending}
onClick={() =>
setAdding({
destination: importDestination,
Expand Down Expand Up @@ -171,7 +200,9 @@ export function AgentControlPanel({
)}
{state.busy && <p role="status">Waiting for the host to confirm…</p>}
{children ? (
children(state, edit, duplicate, remove, importedId, label)
children(state, edit, duplicate, remove, importedId, label, (pubkey) =>
setHandover({ pubkey, destination: importDestination }),
)
) : (
<div className="agent-grid">
{state.data?.agents.map((agent) => (
Expand Down Expand Up @@ -231,6 +262,50 @@ export function AgentControlPanel({
]}
/>
)}
{state.data && handover && handover.destination === importDestination && (
<Dialog.Root
open
modal={false}
onOpenChange={(open) => {
if (!open && !state.busy) setHandover(null);
}}
>
<Dialog.Portal>
<Dialog.Popup
data-buzz-ui=""
className="buzz-dialog agent-controls agent-dialog text-body"
>
<Dialog.Title className="text-heading">
Set up agent here
</Dialog.Title>
<Dialog.Description className="text-body-sm text-secondary">
Set up the imported agent in this community. It will not start
yet.
</Dialog.Description>
{localSource && state.data.localInventoryActions ? (
Comment thread
loganj marked this conversation as resolved.
<LocalInventoryAction
key={`${handover.pubkey}:${handover.destination}:${createOwner}`}
control={control}
agent={localSource || undefined}
destination={handover.destination}
owner={createOwner ?? ""}
disabled={nativeState.busy || state.status !== "ready"}
onPending={setLocalPending}
onUsed={() => setHandover(null)}
/>
) : (
<p>
This app cannot set up this agent yet. Import it first. If it
is already imported, update and restart the desktop app.
</p>
)}
<Button disabled={state.busy} onClick={() => setHandover(null)}>
Close
</Button>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
)}
{adding && (
<AgentCreateDialog
control={control}
Expand Down
6 changes: 4 additions & 2 deletions src/bundled/agents/AgentsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2408,9 +2408,11 @@ it("Use here retries owner confirmation and keeps setup stopped until a separate
if (!card) throw Error("Imported card missing");
expect(within(card).getByRole("button", { name: "Start" })).toBeDisabled();
fireEvent.click(within(card).getByRole("button", { name: "Use here" }));
await within(card).findByText("Confirmation unavailable");
const dialog = screen.getByRole("dialog", { name: "Set up agent here" });
fireEvent.click(within(dialog).getByRole("button", { name: "Use here" }));
await within(dialog).findByText("Confirmation unavailable");
expect(f.calls.some((call) => 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(),
);
Expand Down
15 changes: 14 additions & 1 deletion src/bundled/agents/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
Expand All @@ -153,9 +161,11 @@ export function AgentsPage({
importedId={importedId}
control={control}
connection={connection}
onUseHere={onUseHere}
/>
) : (
<ManagedAgents
onUseHere={onUseHere}
key={`${connection.scope}:${connection.generation}`}
state={state}
label={label}
Expand Down Expand Up @@ -198,6 +208,7 @@ function ManagedAgents({
label,
destination,
headerActions,
onUseHere,
}: {
label(agent: AgentView): string;
state: AgentControlState;
Expand All @@ -209,6 +220,7 @@ function ManagedAgents({
connection: RelaySnapshot;
destination: string;
headerActions: HTMLElement | null;
onUseHere(pubkey: string): void;
}) {
const library = connection.session.agentLibrary;
const snapshot = useSyncExternalStore(
Expand Down Expand Up @@ -251,6 +263,7 @@ function ManagedAgents({
>
<ManagedAgentActions
agent={agent}
onUseHere={onUseHere}
state={state}
control={control}
imported={agent.id === importedId}
Expand Down
Loading
Loading