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; +}