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
42 changes: 42 additions & 0 deletions src/bundled/agents/AgentsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const agent = (name: string, n: number): Agent => ({
type: "test/echo",
owner: "f".repeat(64),
relay: "wss://relay.example",
place: "here",
config: { reply: `${name} says hi` },
skipped: {},
timers: {},
Expand Down Expand Up @@ -105,6 +106,7 @@ function setup(saved: Agent[], { shown = true } = {}) {
for (const listener of listeners) listener();
return created;
}),
finish: vi.fn(async (_: string) => created),
portable: vi.fn(async () => ({ agents: portable, warnings: [] })),
port: vi.fn(async () => created),
save: vi.fn(async () => {}),
Expand Down Expand Up @@ -321,3 +323,43 @@ it("makes a team of chosen agents and lists it with its members", async () => {
expect(agents.removeTeam).toHaveBeenCalledWith("team-1");
expect(screen.getByText("No teams yet.")).toBeInTheDocument();
});

it("shows an agent on another device read-only, and offers only Finish setup and Delete for an unfinished hosted one", async () => {
const user = userEvent.setup();
const far: Agent = {
...agent("Far", 3),
place: "elsewhere",
attention: {},
};
const sky: Agent = {
...agent("Sky", 4),
place: "hosted",
unfinished: true,
attention: {},
};
const { agents } = setup([far, sky]);
const grid = screen.getByRole("listbox", { name: "Agents" });
expect(within(grid).getByText("On another device")).toBeInTheDocument();
expect(within(grid).getByText("Setup unfinished")).toBeInTheDocument();

await user.click(within(grid).getByText("Far"));
expect(screen.getByText(/another of your devices/)).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Open" }),
).not.toBeInTheDocument();
expect(screen.queryByText("Wakes on")).not.toBeInTheDocument();
await user.dblClick(within(grid).getByText("Far"));
expect(screen.getByRole("listbox")).toBeInTheDocument();

await user.click(within(grid).getByText("Sky"));
expect(
screen.queryByRole("button", { name: "Open" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Delete agent" }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Finish setup" }));
expect(agents.finish).toHaveBeenCalledWith(sky.pubkey);
await user.dblClick(within(grid).getByText("Sky"));
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
128 changes: 101 additions & 27 deletions src/bundled/agents/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ type Frame =

/** The type's one-line summary of an agent; its title when it has none. */
function summaryOf(agent: Agent, type: RegisteredAgentType | undefined) {
if (agent.place === "elsewhere") return "On another device";
if (agent.unfinished) return "Setup unfinished";
if (!type) return "Type unavailable";
try {
return type.summary?.(agent).trim() || type.title;
Expand Down Expand Up @@ -100,7 +102,12 @@ export function AgentsPage({
const creating = side === "creating";
const browse = (selected?: string) =>
setFrame(selected ? { view: "browse", selected } : { view: "browse" });
const build = (pubkey: string) => setFrame({ view: "build", pubkey });
const build = (pubkey: string) => {
// A just-made agent may not be shown yet.
const agent = find(pubkey);
if (agent?.place !== "elsewhere" && !agent?.unfinished)
setFrame({ view: "build", pubkey });
};
const newAgent = () => setFrame({ view: "browse", side: "creating" });
const bringOver = () => setFrame({ view: "browse", side: "porting" });
const openTeams = () => setFrame({ view: "browse", side: "teams" });
Expand Down Expand Up @@ -145,13 +152,14 @@ export function AgentsPage({
) : side === "teams" ? (
<Teams
agents={agents}
saved={snapshot.agents}
saved={snapshot.agents.filter((agent) => agent.place === "here")}
teams={snapshot.teams}
onClose={() => browse()}
/>
) : selected ? (
<AgentPeek
key={selected.pubkey}
agents={agents}
agent={selected}
type={typeOf(selected)}
channels={channels}
Expand Down Expand Up @@ -377,12 +385,14 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
}

function AgentPeek({
agents,
agent,
type,
channels,
onBuild,
onClose,
}: {
agents: Agents;
agent: Agent;
type: RegisteredAgentType | undefined;
channels: readonly ChannelChoice[];
Expand All @@ -391,30 +401,62 @@ function AgentPeek({
}) {
const Peek = type?.Peek;
const summary = summaryOf(agent, type);
const [finishing, setFinishing] = useState(false);
const [error, setError] = useState("");
const finish = async () => {
setFinishing(true);
setError("");
try {
await agents.finish(agent.pubkey);
} catch (reason) {
setError(message(reason));
}
setFinishing(false);
};
if (agent.place === "elsewhere")
return (
<div className={styles.sideBody}>
<PeekIdentity agent={agent} detail={summary} onClose={onClose} />
<p className="m-0 text-body-sm text-secondary">
Its key is on another of your devices, which runs it. Open Agents
there to change or delete it.
</p>
</div>
);
return (
<div className={styles.sideBody}>
<div className={styles.peekIdentity}>
<span className={styles.portraitLarge}>
<AgentPortrait agent={agent} size="fill" />
</span>
<div className="grid min-w-0 flex-1 gap-0.5">
<h3 className="m-0 truncate text-heading">{agent.name}</h3>
<PeekIdentity
agent={agent}
detail={`${type?.title ?? "Type unavailable"}${
type && !Peek && summary !== type.title ? ` · ${summary}` : ""
}`}
onClose={onClose}
/>
{agent.unfinished ? (
<>
<p className="m-0 text-body-sm text-secondary">
{type?.title ?? "Type unavailable"}
{type && !Peek && summary !== type.title ? ` · ${summary}` : ""}
Its provider made it, but its setup did not finish.
</p>
</div>
<IconButton
aria-label="Close"
size="sm"
icon={<XIcon size={16} aria-hidden="true" />}
onClick={onClose}
/>
</div>
<Button variant="primary" onClick={onBuild}>
Open
</Button>
{Peek ? (
<Button
variant="primary"
loading={finishing}
onClick={() => void finish()}
>
Finish setup
</Button>
{error && (
<p role="alert" className="m-0 text-body-sm text-danger">
{error}
</p>
)}
<AgentDelete agents={agents} agent={agent} onRemoved={onClose} />
</>
) : (
<Button variant="primary" onClick={onBuild}>
Comment on lines +452 to +455

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve keyboard focus when Finish setup succeeds

When agents.finish updates this agent to finished, this conditional replaces the fragment containing the focused Finish setup button with a different Open button. AgentPeek has no focus handoff, so keyboard activation loses its focused control on success instead of allowing the user to continue into the agent. Keep the action mounted across this transition, or explicitly transfer focus to Open only if it was still on Finish setup. This follows DESIGN.md’s States, feedback, and motion rule; the new page test mocks finish without changing the snapshot, so it never exercises the transition.

Open
</Button>
)}
{Peek && !agent.unfinished ? (
<div className={styles.peekPlugin}>
<Peek agent={agent} />
</div>
Expand All @@ -423,9 +465,39 @@ function AgentPeek({
Enable the plugin that provides this agent's type to run or change it.
</p>
) : null}
<Section title="Wakes on">
<AttentionSummary agent={agent} channels={channels} />
</Section>
{agent.place === "here" && (
<Section title="Wakes on">
<AttentionSummary agent={agent} channels={channels} />
</Section>
)}
</div>
);
}

function PeekIdentity({
agent,
detail,
onClose,
}: {
agent: Agent;
detail: string;
onClose(): void;
}) {
return (
<div className={styles.peekIdentity}>
<span className={styles.portraitLarge}>
<AgentPortrait agent={agent} size="fill" />
</span>
<div className="grid min-w-0 flex-1 gap-0.5">
<h3 className="m-0 truncate text-heading">{agent.name}</h3>
<p className="m-0 text-body-sm text-secondary">{detail}</p>
</div>
<IconButton
aria-label="Close"
size="sm"
icon={<XIcon size={16} aria-hidden="true" />}
onClick={onClose}
/>
</div>
);
}
Expand Down Expand Up @@ -492,14 +564,16 @@ function NewAgent({
key={item.key}
value={item.key}
variant="card"
label={item.title}
label={item.remote ? `${item.title} (hosted)` : item.title}
{...(item.description ? { description: item.description } : {})}
/>
))}
</RadioGroup>
</Field>
<p className="m-0 text-body-sm text-secondary">
It gets its own key on this device and runs while the app is open.
{chosen?.remote
? "Its provider keeps its key and runs it, whether or not this app is open."
: "It gets its own key on this device and runs while the app is open."}
</p>
{error && (
<p role="alert" className="m-0 text-body-sm text-danger">
Expand Down
2 changes: 1 addition & 1 deletion src/bundled/profiles/ProfilePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ function ProfileDetails({
{profile?.about && (
<p className={styles.about}>{profile.about}</p>
)}
{plugin && agents && (
{plugin && agents && plugin.agent.place !== "elsewhere" && (
<div>
<AgentDelete
agents={agents}
Expand Down
1 change: 1 addition & 0 deletions src/features/agents/AgentManage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const agent = {
type: "test/echo",
owner: "f".repeat(64),
relay: "wss://relay.example",
place: "here",
config: {},
attention: {},
skipped: {},
Expand Down
6 changes: 5 additions & 1 deletion src/features/agents/AgentManage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ export function AgentDelete({
open={confirming}
onOpenChange={setConfirming}
title={`Delete ${agent.name}?`}
description="This deletes its key from this device. Then, whenever its community is open, it leaves every channel and is archived so it no longer appears in member lists or mention suggestions. It can't be undone."
description={
agent.place === "hosted"
? "Its provider deletes it and its key, then it leaves every channel and is archived so it no longer appears in member lists or mention suggestions. It can't be undone."
: "This deletes its key from this device. Then, whenever its community is open, it leaves every channel and is archived so it no longer appears in member lists or mention suggestions. It can't be undone."
}
preventClose={removing}
actions={
<>
Expand Down
1 change: 1 addition & 0 deletions src/features/agents/AttentionPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const agent: Agent = {
type: "test/echo",
owner: "f".repeat(64),
relay: "wss://relay.example",
place: "here",
config: {},
skipped: {},
timers: {},
Expand Down
80 changes: 79 additions & 1 deletion src/features/agents/relay-removal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { afterEach, expect, it } from "vitest";
import { PublishRejected } from "../relay/outbox";
import { createRelaySession } from "../relay/session";
import { archiveRelay, keypair, signed, type Key } from "../relay/testing";
import { removeRelayAgent } from "./relay-removal";
import { publishAgentRecord, removeRelayAgent } from "./relay-removal";

const viewer = keypair(),
relay = keypair(),
Expand Down Expand Up @@ -320,3 +320,81 @@ it("a cancelled removal signs and publishes nothing", async () => {
expect(fixture.published).toEqual([]);
expect(fixture.signedBy).toEqual([]);
});

it("publishes the owner's record of an agent with its runtime", async () => {
const { fixture, instance } = setup();
await publishAgentRecord(
instance.session,
{
pubkey: agent.pubkey,
name: "Agent",
picture: "https://example.test/a.png",
runtime: "example/echo",
},
new AbortController().signal,
);
const [listed] = fixture.published;
expect(listed).toMatchObject({ kind: 30177, pubkey: viewer.pubkey });
expect(JSON.parse(listed?.content ?? "")).toEqual({
name: "Agent",
picture: "https://example.test/a.png",
parallelism: 1,
respond_to: "owner-only",
});
expect(listed?.tags).toEqual([
["d", agent.pubkey],
["runtime", "example/echo"],
["client-id", expect.any(String)],
]);
expect(instance.session.outbox?.snapshot()).toEqual([]);
});

it("keeps the settings and tags of the record it replaces", async () => {
const { fixture, instance } = setup();
const written = await publishAgentRecord(
instance.session,
{
pubkey: agent.pubkey,
name: "Renamed",
runtime: "example/echo",
prior: {
content: {
name: "Agent",
picture: "https://example.test/old.png",
parallelism: 3,
respond_to: "anyone",
persona_id: "p1",
},
tags: [
["d", agent.pubkey],
["runtime", "example/old"],
["a", "30175:owner:p1"],
["client-id", "old"],
],
},
},
new AbortController().signal,
);
const [listed] = fixture.published;
const content = {
name: "Renamed",
parallelism: 3,
respond_to: "anyone",
persona_id: "p1",
};
expect(JSON.parse(listed?.content ?? "")).toEqual(content);
expect(listed?.tags).toEqual([
["d", agent.pubkey],
["runtime", "example/echo"],
["a", "30175:owner:p1"],
["client-id", expect.any(String)],
]);
expect(written).toEqual({
content,
tags: [
["d", agent.pubkey],
["runtime", "example/echo"],
["a", "30175:owner:p1"],
],
});
});
Loading