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
15 changes: 15 additions & 0 deletions desktop/src/features/agents/ui/AgentsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -278,6 +279,20 @@ export function AgentsView() {
onDeletePersona={personas.openDelete}
/>

<WorkspaceAgentsSection
error={
agents.relayAgentsQuery.error instanceof Error
? agents.relayAgentsQuery.error
: null
}
isLoading={agents.managedAgentsQuery.isLoading}
managedAgents={agents.managedAgents}
relayAgents={agents.relayAgentsQuery.data}
onOpenAgentProfile={(pubkey) => {
openProfilePanel?.(pubkey);
}}
/>

<TeamsSection
error={
teamActions.teamsQuery.error instanceof Error
Expand Down
158 changes: 158 additions & 0 deletions desktop/src/features/agents/ui/WorkspaceAgentsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import * as React from "react";

import { useAgentAvailabilityLookup } from "@/features/agents/lib/useAgentAvailability";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type {
ManagedAgent,
PresenceStatus,
RelayAgent,
} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton";
import { SectionHeader } from "@/shared/ui/PageHeader";
import { AgentIdentityCard } from "./AgentIdentityCard";
import { AgentRuntimeAvatarControl } from "./AgentRuntimeAvatarControl";
import { IDENTITY_CARD_GRID_CLASS } from "./UnifiedAgentsSection";
import {
describeWorkspaceAgent,
resolveWorkspaceAgentAvailability,
resolveWorkspaceOwnerLabel,
selectWorkspaceAgents,
} from "./workspaceAgents";

type WorkspaceAgentsSectionProps = {
error: Error | null;
/** Managed agents must be known before dedupe, or own agents flash here. */
isLoading: boolean;
managedAgents: ManagedAgent[];
/** `undefined` until the relay directory has loaded. */
relayAgents: RelayAgent[] | undefined;
onOpenAgentProfile: (pubkey: string) => 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 (
<section
className="relative space-y-4"
data-testid="agents-library-workspace"
>
<SectionHeader
title="Workspace agents"
description="Agents run by other members of this workspace."
/>

{isPending ? (
<div className={IDENTITY_CARD_GRID_CLASS}>
<IdentityCardSkeleton
footerSubtitleWidthClass="w-20"
footerTitleWidthClass="w-28"
/>
<IdentityCardSkeleton
footerSubtitleWidthClass="w-16"
footerTitleWidthClass="w-24"
/>
</div>
) : agents.length > 0 ? (
<div className={IDENTITY_CARD_GRID_CLASS}>
{agents.map((agent) => (
<WorkspaceAgentCard
agent={agent}
availability={resolveWorkspaceAgentAvailability(
getAvailability(agent.pubkey),
agent.status,
)}
avatarUrl={
profiles?.[normalizePubkey(agent.pubkey)]?.avatarUrl ?? null
}
key={agent.pubkey}
ownerLabel={resolveWorkspaceOwnerLabel(
agent.ownerPubkey,
agent.ownerPubkey
? profiles?.[normalizePubkey(agent.ownerPubkey)]
: undefined,
)}
onOpenAgentProfile={onOpenAgentProfile}
/>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No agents from other workspace members yet.
</p>
)}

{error ? (
<p className="w-full rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
</p>
) : null}
</section>
);
}

function WorkspaceAgentCard({
agent,
availability,
avatarUrl,
ownerLabel,
onOpenAgentProfile,
}: {
agent: RelayAgent;
availability: PresenceStatus | undefined;
avatarUrl: string | null;
ownerLabel: string | null;
onOpenAgentProfile: (pubkey: string) => void;
}) {
return (
<AgentIdentityCard
ariaLabel={`${agent.name} agent profile`}
avatar={
// Another member's agent has no Start/Stop here; `isActive` only
// selects the presence-dot face over the Start badge (see the control's
// prop comment), so `onStart` can never fire.
<AgentRuntimeAvatarControl
activeTestId={`workspace-agent-presence-${agent.pubkey}`}
availability={availability}
avatarUrl={avatarUrl}
isActive
isStarting={false}
label={agent.name}
startTestId={`workspace-agent-start-${agent.pubkey}`}
onStart={() => {}}
/>
}
avatarUrl={avatarUrl}
dataTestId={`workspace-agent-${agent.pubkey}`}
label={agent.name}
subtitle={describeWorkspaceAgent(agent, ownerLabel)}
onClick={() => onOpenAgentProfile(agent.pubkey)}
/>
);
}
115 changes: 115 additions & 0 deletions desktop/src/features/agents/ui/workspaceAgents.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
73 changes: 73 additions & 0 deletions desktop/src/features/agents/ui/workspaceAgents.ts
Original file line number Diff line number Diff line change
@@ -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<RelayAgent, "agentType" | "channels">,
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;
}