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
16 changes: 10 additions & 6 deletions docs/solo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,16 @@ behavior on the label, so a rename touches copy only.
recipient tags for both owners and guests. Saved chats offer the chat's people
and your available agents; an untagged message never infers an agent recipient.
Only available agents can be enrolled through tagging—outside humans must
already have been shared into the chat. Empty new-chat drafts start tagged with the device's default
agent for this account/community. **Agents → right-click → Make default agent**
changes that choice; its card shows **Default agent**. An unavailable choice
falls back to the first managed agent, then the first selectable agent, only
after agent evidence is complete and the requested archive read has settled.
An available explicitly chosen default does not wait for fallback evidence.
already have been shared into the chat. Empty new-chat drafts start tagged with the device's main
agent for this account/community. **Agents → right-click or "..." → Make main
agent** changes that choice; its card shows **Main agent**. The choice stays
until it is changed or the agent is deleted, and it is still tagged when its
runtime is signed out or broken. Without a choice, or after the chosen agent
is deleted, the first managed agent, then the first selectable agent, becomes
the main agent and is saved, only after agent evidence is complete and the
requested archive read has settled. A chosen agent missing for another reason
is stood in for without replacing the choice; the stand-in can be made main.
An available explicit choice does not wait for fallback evidence.
Follow-ups keep all exact agents notified by the previous send, just like
Team threads, including the shared Remember mentioned agents preference.
The account default never replaces recipients in an existing chat.
Expand Down
10 changes: 6 additions & 4 deletions src/bundled/agents/AgentCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -378,10 +378,12 @@ html[data-keyboard-navigation] .card:focus-visible {
}
}
/* Placeholder treatment until the card's status styling is designed. */
/* Primary text follows the mode, as the card backdrop does: dark on light
cards and light on dark ones. */
.tag {
position: absolute;
inset-block-start: var(--space-3);
inset-inline-start: var(--space-3);
color: var(--text-subtle);
@apply text-caption;
inset-block-start: var(--space-4);
inset-inline-start: var(--space-4);
color: var(--text-primary);
@apply text-label-sm;
}
2 changes: 1 addition & 1 deletion src/bundled/agents/AgentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export function AgentCard({
menu,
...props
}: Omit<ComponentProps<"div">, "className" | "children"> & {
/** A short status beside the name, such as "Default agent". */
/** A short status in the corner, such as "Main agent". */
tag?: ReactNode;
agent: Portrayed;
community?: string | undefined;
Expand Down
129 changes: 125 additions & 4 deletions src/bundled/agents/AgentsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
} from "../../features/agents/service";
import type { OldTeam, PortableAgent } from "../../features/agents/native";
import { localStore } from "../../features/agents/state";
import { setDefaultAgent } from "../../features/solo/default-agent";
import type { RelayData } from "../../features/relay/service";
import type { Navigation } from "../../features/navigation/controller";
import type { PageNavigation } from "../../features/navigation/service";
Expand Down Expand Up @@ -2188,15 +2189,15 @@ it("retains the selected profile draft while viewing teams", async () => {
expect(agents.save).not.toHaveBeenCalled();
});

it("announces the default agent and presents its menu status without a fake toggle", async () => {
it("announces the main agent and presents its menu status without a fake toggle", async () => {
const ada = agent("Ada", 1);
const view = setup([ada]);
const choices = {
status: "ready",
complete: true,
pending: false,
selectable: [{ pubkey: ada.pubkey, name: ada.name, managed: true }],
archives: { status: "unavailable" },
archives: { status: "unavailable", archived: [] },
};
const connection = {
status: "ready",
Expand All @@ -2214,13 +2215,89 @@ it("announces the default agent and presents its menu status without a fake togg
subscribe: () => () => {},
} as unknown as RelayData;
view.rerender(<AgentsPage agents={view.agents} relay={relay} />);
const card = screen.getByRole("gridcell", { name: "Ada, Default agent" });
const card = screen.getByRole("gridcell", { name: "Ada, Main agent" });
expect(within(card).getByText("Main agent")).toBeVisible();
fireEvent.keyDown(card, { key: "F10", shiftKey: true });
const menu = await screen.findByRole("menu", { name: "Actions for Ada" });
expect(within(menu).getByText("Default agent")).toBeVisible();
expect(within(menu).getByText("Main agent")).toBeVisible();
// Right-click carries the same actions as the card's "..." menu.
expect(
within(menu).getByRole("menuitem", { name: "Copy public key" }),
).toBeVisible();
expect(within(menu).queryByRole("menuitemcheckbox")).toBeNull();
});

it("offers Make main agent from the card's actions button", async () => {
const user = userEvent.setup();
const ada = agent("Ada", 1);
const bea = agent("Bea", 2);
const view = setup([ada, bea]);
const choices = {
status: "ready",
complete: true,
pending: false,
selectable: [
{ pubkey: ada.pubkey, name: ada.name, managed: true },
{ pubkey: bea.pubkey, name: bea.name, managed: true },
],
archives: { status: "unavailable", archived: [] },
};
const connection = {
status: "ready",
scope: "main-card-button",
session: {
agentChoices: {
snapshot: () => choices,
subscribe: () => () => {},
retain: () => () => {},
},
},
};
const relay = {
snapshot: () => connection,
subscribe: () => () => {},
} as unknown as RelayData;
view.rerender(<AgentsPage agents={view.agents} relay={relay} />);
expect(
screen.getByRole("gridcell", { name: "Ada, Main agent" }),
).toBeVisible();
const card = screen.getByRole("gridcell", { name: "Bea" });
await user.click(
within(card).getByRole("button", { name: "Actions for Bea" }),
);
await user.click(
await screen.findByRole("menuitem", { name: "Make main agent" }),
);
expect(
await screen.findByRole("gridcell", { name: "Bea, Main agent" }),
).toBeVisible();
expect(screen.getByRole("gridcell", { name: "Ada" })).toBeVisible();
// Right-click opens the same actions, including the main agent choice.
fireEvent.contextMenu(screen.getByRole("gridcell", { name: "Ada" }));
const menu = await screen.findByRole("menu", { name: "Actions for Ada" });
await user.click(
within(menu).getByRole("menuitem", { name: "Make main agent" }),
);
expect(
await screen.findByRole("gridcell", { name: "Ada, Main agent" }),
).toBeVisible();
expect(screen.getByRole("gridcell", { name: "Bea" })).toBeVisible();
});

it("opens deletion from the card's right-click menu", async () => {
const user = userEvent.setup();
const { agents } = setup([agent("Ada", 1)]);
fireEvent.contextMenu(screen.getByRole("gridcell", { name: "Ada" }));
const menu = await screen.findByRole("menu", { name: "Actions for Ada" });
await user.click(
within(menu).getByRole("menuitem", { name: "Delete agent" }),
);
const dialog = await screen.findByRole("dialog", { name: "Delete Ada?" });
expect(agents.remove).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole("button", { name: "Delete" }));
expect(agents.remove).toHaveBeenCalledWith(agent("Ada", 1).pubkey);
});

it("opens a Marketplace tile in the profile pane and returns focus on close and import", async () => {
const user = userEvent.setup();
const view = setup([], {
Expand Down Expand Up @@ -2295,3 +2372,47 @@ it("opens a Marketplace tile in the profile pane and returns focus on close and
owner.dispose();
}
});

it("offers Make main agent on a stand-in for a missing chosen agent", async () => {
const ada = agent("Ada", 1);
const view = setup([ada]);
const choices = {
status: "ready",
complete: true,
pending: false,
selectable: [{ pubkey: ada.pubkey, name: ada.name, managed: true }],
archives: { status: "unavailable", archived: [] },
};
const connection = {
status: "ready",
scope: "stand-in",
session: {
agentChoices: {
snapshot: () => choices,
subscribe: () => () => {},
retain: () => () => {},
},
},
};
const relay = {
snapshot: () => connection,
subscribe: () => () => {},
} as unknown as RelayData;
// The chosen agent isn't among the choices, and wasn't deleted.
act(() => {
setDefaultAgent("stand-in", "d".repeat(64));
});
view.rerender(<AgentsPage agents={view.agents} relay={relay} />);
const card = screen.getByRole("gridcell", { name: "Ada, Main agent" });
fireEvent.contextMenu(card);
const menu = await screen.findByRole("menu", { name: "Actions for Ada" });
fireEvent.click(
within(menu).getByRole("menuitem", { name: "Make main agent" }),
);
fireEvent.contextMenu(card);
expect(
within(
await screen.findByRole("menu", { name: "Actions for Ada" }),
).queryByRole("menuitem", { name: "Make main agent" }),
).toBeNull();
});
Loading
Loading