From 7f7b39bb69c8ba08ae5b1b4c02b45a5cf66c70c7 Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Fri, 11 Sep 2026 13:59:28 -0500 Subject: [PATCH] feat(desktop): list workspace agents on the Agents screen The Agents screen only rendered the viewer's own managed agents and persona library, so agents run by other workspace members (already discovered through list_relay_agents for mentions, the tray and the sidebar) never appeared there. Add a read-only "Workspace agents" section after the unified agents section. It shows every relay-directory agent that is not one of the viewer's managed agents, deduplicated by pubkey, with name, presence dot (relay presence first, directory status as fallback), "Managed by ", runtime and channel count. Clicking a card opens the agent's profile panel. No start, stop or delete controls: the viewer does not own these agents. Grouping, labelling and availability rules live in pure helpers in workspaceAgents.ts with node unit tests; managed-agent code paths are untouched. Signed-off-by: Andrew Davis Co-Authored-By: Claude Fable 5.1 --- desktop/src/features/agents/ui/AgentsView.tsx | 15 ++ .../agents/ui/WorkspaceAgentsSection.tsx | 158 ++++++++++++++++++ .../agents/ui/workspaceAgents.test.mjs | 115 +++++++++++++ .../src/features/agents/ui/workspaceAgents.ts | 73 ++++++++ 4 files changed, 361 insertions(+) create mode 100644 desktop/src/features/agents/ui/WorkspaceAgentsSection.tsx create mode 100644 desktop/src/features/agents/ui/workspaceAgents.test.mjs create mode 100644 desktop/src/features/agents/ui/workspaceAgents.ts diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index a31fec44c49..7ae2c7ee53a 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -20,6 +20,7 @@ import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; import { TeamsSection } from "./TeamsSection"; import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; +import { WorkspaceAgentsSection } from "./WorkspaceAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; @@ -278,6 +279,20 @@ export function AgentsView() { onDeletePersona={personas.openDelete} /> + { + openProfilePanel?.(pubkey); + }} + /> + void; +}; + +/** Read-only view of agents other workspace members run; no lifecycle controls. */ +export function WorkspaceAgentsSection({ + error, + isLoading, + managedAgents, + relayAgents, + onOpenAgentProfile, +}: WorkspaceAgentsSectionProps) { + const agents = React.useMemo( + () => selectWorkspaceAgents(relayAgents, managedAgents), + [relayAgents, managedAgents], + ); + const agentPubkeys = React.useMemo( + () => agents.map((agent) => agent.pubkey), + [agents], + ); + // One profile batch covers card avatars and "Managed by" owner names. + const profilePubkeys = React.useMemo( + () => + agents.flatMap((agent) => + agent.ownerPubkey ? [agent.pubkey, agent.ownerPubkey] : [agent.pubkey], + ), + [agents], + ); + const profiles = useUsersBatchQuery(profilePubkeys).data?.profiles; + const { getAvailability } = useAgentAvailabilityLookup(agentPubkeys); + const isPending = isLoading || relayAgents === undefined; + + return ( +
+ + + {isPending ? ( +
+ + +
+ ) : agents.length > 0 ? ( +
+ {agents.map((agent) => ( + + ))} +
+ ) : ( +

+ No agents from other workspace members yet. +

+ )} + + {error ? ( +

+ {error.message} +

+ ) : null} +
+ ); +} + +function WorkspaceAgentCard({ + agent, + availability, + avatarUrl, + ownerLabel, + onOpenAgentProfile, +}: { + agent: RelayAgent; + availability: PresenceStatus | undefined; + avatarUrl: string | null; + ownerLabel: string | null; + onOpenAgentProfile: (pubkey: string) => void; +}) { + return ( + {}} + /> + } + avatarUrl={avatarUrl} + dataTestId={`workspace-agent-${agent.pubkey}`} + label={agent.name} + subtitle={describeWorkspaceAgent(agent, ownerLabel)} + onClick={() => onOpenAgentProfile(agent.pubkey)} + /> + ); +} diff --git a/desktop/src/features/agents/ui/workspaceAgents.test.mjs b/desktop/src/features/agents/ui/workspaceAgents.test.mjs new file mode 100644 index 00000000000..c22bcfee776 --- /dev/null +++ b/desktop/src/features/agents/ui/workspaceAgents.test.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + describeWorkspaceAgent, + resolveWorkspaceAgentAvailability, + resolveWorkspaceOwnerLabel, + selectWorkspaceAgents, +} from "./workspaceAgents.ts"; + +const MINE = "a".repeat(64); +const THEIRS = "b".repeat(64); +const OWNER = "c".repeat(64); + +function relayAgent(overrides = {}) { + return { + pubkey: THEIRS, + ownerPubkey: OWNER, + name: "Scout", + agentType: "omp", + channels: ["general"], + channelIds: ["ch-1"], + capabilities: [], + status: "unknown", + respondTo: null, + respondToAllowlist: [], + ...overrides, + }; +} + +test("excludes the viewer's managed agents by pubkey, case-insensitively", () => { + const selected = selectWorkspaceAgents( + [relayAgent({ pubkey: MINE.toUpperCase() }), relayAgent()], + [{ pubkey: ` ${MINE}` }], + ); + + assert.deepEqual( + selected.map((agent) => agent.pubkey), + [THEIRS], + ); +}); + +test("collapses duplicate relay entries for one pubkey", () => { + const selected = selectWorkspaceAgents( + [relayAgent({ name: "First" }), relayAgent({ name: "Second" })], + [], + ); + + assert.deepEqual( + selected.map((agent) => agent.name), + ["First"], + ); +}); + +test("blank names fall back to the canonical truncated pubkey", () => { + const [agent] = selectWorkspaceAgents([relayAgent({ name: " " })], []); + + assert.equal(agent.name, `${"b".repeat(8)}…${"b".repeat(4)}`); +}); + +test("sorts by display name and tolerates undefined inputs", () => { + const selected = selectWorkspaceAgents( + [ + relayAgent({ pubkey: "1".repeat(64), name: "Zed" }), + relayAgent({ pubkey: "2".repeat(64), name: "Ada" }), + ], + undefined, + ); + + assert.deepEqual( + selected.map((agent) => agent.name), + ["Ada", "Zed"], + ); + assert.deepEqual(selectWorkspaceAgents(undefined, undefined), []); +}); + +test("describeWorkspaceAgent joins owner, runtime, and channel count", () => { + assert.equal( + describeWorkspaceAgent(relayAgent(), "Alice"), + "Managed by Alice · omp · in 1 channel", + ); + assert.equal( + describeWorkspaceAgent( + relayAgent({ agentType: "buzz-acp", channels: ["a", "b", "c"] }), + null, + ), + "buzz-acp · in 3 channels", + ); + assert.equal( + describeWorkspaceAgent(relayAgent({ agentType: " ", channels: [] }), null), + "in 0 channels", + ); +}); + +test("owner label prefers display name, then name, then truncated pubkey", () => { + assert.equal(resolveWorkspaceOwnerLabel(null, { displayName: "Alice" }), null); + assert.equal( + resolveWorkspaceOwnerLabel(OWNER, { displayName: " Alice ", name: "al" }), + "Alice", + ); + assert.equal( + resolveWorkspaceOwnerLabel(OWNER, { displayName: "", name: "al" }), + "al", + ); + assert.equal( + resolveWorkspaceOwnerLabel(OWNER, undefined), + `${"c".repeat(8)}…${"c".repeat(4)}`, + ); +}); + +test("availability prefers relay presence over the directory snapshot", () => { + assert.equal(resolveWorkspaceAgentAvailability("offline", "online"), "offline"); + assert.equal(resolveWorkspaceAgentAvailability(undefined, "away"), "away"); + assert.equal(resolveWorkspaceAgentAvailability(undefined, "unknown"), undefined); +}); diff --git a/desktop/src/features/agents/ui/workspaceAgents.ts b/desktop/src/features/agents/ui/workspaceAgents.ts new file mode 100644 index 00000000000..a17756b502f --- /dev/null +++ b/desktop/src/features/agents/ui/workspaceAgents.ts @@ -0,0 +1,73 @@ +import type { PresenceStatus, RelayAgent } from "@/shared/api/types"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; + +/** + * Relay-discovered agents run by other workspace members, for the read-only + * "Workspace agents" section of the Agents screen. + * + * Excludes every pubkey the viewer already manages (those render as persona / + * custom / unknown cards), collapses duplicate relay entries, substitutes the + * canonical truncated pubkey for a blank name so every card has a label, and + * sorts by that label. The managed side is structurally typed on `{ pubkey }` + * so node unit tests don't need full `ManagedAgent` values. + */ +export function selectWorkspaceAgents( + relayAgents: readonly RelayAgent[] | undefined, + managedAgents: readonly { pubkey: string }[] | undefined, +): RelayAgent[] { + const excluded = new Set( + (managedAgents ?? []).map((agent) => normalizePubkey(agent.pubkey)), + ); + const workspace: RelayAgent[] = []; + for (const agent of relayAgents ?? []) { + const pubkey = normalizePubkey(agent.pubkey); + if (excluded.has(pubkey)) continue; + excluded.add(pubkey); + const name = agent.name.trim() || truncatePubkey(agent.pubkey); + workspace.push(name === agent.name ? agent : { ...agent, name }); + } + return workspace.sort((left, right) => left.name.localeCompare(right.name)); +} + +/** Card second line: owner, runtime, and channel count, dot-separated. */ +export function describeWorkspaceAgent( + agent: Pick, + ownerLabel: string | null, +): string { + const count = agent.channels.length; + return [ + ownerLabel ? `Managed by ${ownerLabel}` : null, + agent.agentType.trim() || null, + `in ${count} channel${count === 1 ? "" : "s"}`, + ] + .filter((part) => part !== null) + .join(" · "); +} + +/** "Managed by" label: profile display name, kind-0 name, then truncated key. */ +export function resolveWorkspaceOwnerLabel( + ownerPubkey: string | null, + summary: + | { displayName?: string | null; name?: string | null } + | null + | undefined, +): string | null { + if (!ownerPubkey) return null; + return ( + summary?.displayName?.trim() || + summary?.name?.trim() || + truncatePubkey(ownerPubkey) + ); +} + +/** + * Relay presence is the availability authority (see docs/agent-availability.md); + * the directory's own status snapshot only fills in while that read is unknown. + */ +export function resolveWorkspaceAgentAvailability( + presence: PresenceStatus | undefined, + status: RelayAgent["status"], +): PresenceStatus | undefined { + if (presence) return presence; + return status === "unknown" ? undefined : status; +}