diff --git a/apps/desktop/src/components/agents/agent-card.tsx b/apps/desktop/src/components/agents/agent-card.tsx
index e094c73..46405f5 100644
--- a/apps/desktop/src/components/agents/agent-card.tsx
+++ b/apps/desktop/src/components/agents/agent-card.tsx
@@ -20,22 +20,22 @@ import { AgentStatus, useAgentLiveRun, useAgentMood, useAgentPause, useDuplicate
export function AgentCard({
agent,
routineCount,
+ showScope = true,
onRunTask,
onDelete,
}: {
agent: Agent;
routineCount: number;
+ /** Off where the card sits under its workspace's heading. */
+ showScope?: boolean;
onRunTask: (agent: Agent) => void;
onDelete: (agent: Agent) => void;
}) {
- const navigate = useNavigate();
const modelLabel = useModelLabel();
const live = useAgentLiveRun(agent.id);
const running = !!live || agent.status === "running";
const mood = useAgentMood(agent);
const chat = useStartAgentChat();
- const toggle = useToggleAgent();
- const duplicate = useDuplicateAgent();
const { pause, resume } = useAgentPause();
const paused = (agent.pausedRuns ?? 0) > 0;
const chatting = chat.isPending && chat.variables?.agent.id === agent.id;
@@ -71,89 +71,120 @@ export function AgentCard({
{running && }
-
-
-
-
-
-
-
- chat.mutate(agent)}>
- New chat
-
- onRunTask(agent)} disabled={!agent.enabled}>
- Run task…
-
- {running && (
- pause.mutate(agent)}>
- Pause
-
- )}
- {paused && (
- resume.mutate(agent)} disabled={!agent.enabled}>
- Continue
-
- )}
- navigate(`/agents/${agent.id}/routines`)}>
- Automations
-
- navigate(`/agents/${agent.id}/settings`)}>
- Edit
-
- {!agent.isDefault && (
- duplicate.mutate(agent)} disabled={duplicate.isPending}>
- Duplicate
-
- )}
-
- toggle.mutate({ id: agent.id, enabled: !agent.enabled })}>
- {agent.enabled ? : } {agent.enabled ? "Switch off" : "Switch on"}
-
- {!agent.isDefault && (
- onDelete(agent)}>
- Delete
-
- )}
-
-
+
-
+
{agent.description || No description }
-
-
-
- {agent.model ? modelLabel(agent.model) : "Default model"}
-
-
- {routineCount}
-
-
-
- {agent.lastRunAt ? formatDistanceToNowStrict(new Date(agent.lastRunAt), { addSuffix: true }) : "Never"}
-
+
+
+ {showScope && }
+
+ {routineCount}
+
+
+
+ {agent.lastRunAt ? formatDistanceToNowStrict(new Date(agent.lastRunAt), { addSuffix: true }) : "Never"}
+
+ {agent.model && (
+
+ {modelLabel(agent.model)}
+
+ )}
+
+
+
chat.mutate(agent)} disabled={chatting}>
+ {chatting ? : } Chat
+
+ {paused ? (
+
resume.mutate(agent)} disabled={resume.isPending || !agent.enabled}>
+ {resume.isPending ? : } Continue
+
+ ) : running ? (
+
pause.mutate(agent)} disabled={pause.isPending}>
+ {pause.isPending ? : } Pause
+
+ ) : (
+
onRunTask(agent)} disabled={!agent.enabled}>
+ Run
+
+ )}
+
+
+ );
+}
-
-
chat.mutate(agent)} disabled={chatting}>
- {chatting ? : } Chat
+/** Everything you can do with an agent, behind its "…" button. */
+export function AgentMenu({
+ agent,
+ chat,
+ onRunTask,
+ onDelete,
+ className,
+}: {
+ agent: Agent;
+ /** The chat starter of the card or row, so both show the same pending state. */
+ chat: ReturnType;
+ onRunTask: (agent: Agent) => void;
+ onDelete: (agent: Agent) => void;
+ className?: string;
+}) {
+ const navigate = useNavigate();
+ const live = useAgentLiveRun(agent.id);
+ const running = !!live || agent.status === "running";
+ const chatting = chat.isPending && chat.variables?.agent.id === agent.id;
+ const toggle = useToggleAgent();
+ const duplicate = useDuplicateAgent();
+ const { pause, resume } = useAgentPause();
+ const paused = (agent.pausedRuns ?? 0) > 0;
+ return (
+
+
+
+
- {paused ? (
- resume.mutate(agent)} disabled={resume.isPending || !agent.enabled}>
- {resume.isPending ? : } Continue
-
- ) : running ? (
- pause.mutate(agent)} disabled={pause.isPending}>
- {pause.isPending ? : } Pause
-
- ) : (
- onRunTask(agent)} disabled={!agent.enabled}>
- Run task
-
+
+
+ chat.mutate(agent)} disabled={chatting}>
+ New chat
+
+ onRunTask(agent)} disabled={!agent.enabled}>
+ Run task…
+
+ {running && (
+ pause.mutate(agent)}>
+ Pause
+
)}
-
-
+ {paused && (
+ resume.mutate(agent)} disabled={!agent.enabled}>
+ Continue
+
+ )}
+ navigate(`/agents/${agent.id}/routines`)}>
+ Automations
+
+ navigate(`/agents/${agent.id}/settings`)}>
+ Edit
+
+ {!agent.isDefault && (
+ duplicate.mutate(agent)} disabled={duplicate.isPending}>
+ Duplicate
+
+ )}
+
+ toggle.mutate({ id: agent.id, enabled: !agent.enabled })}>
+ {agent.enabled ? : } {agent.enabled ? "Switch off" : "Switch on"}
+
+ {!agent.isDefault && (
+ onDelete(agent)}>
+ Delete
+
+ )}
+
+
);
}
diff --git a/apps/desktop/src/components/agents/agent-groups.tsx b/apps/desktop/src/components/agents/agent-groups.tsx
new file mode 100644
index 0000000..403a824
--- /dev/null
+++ b/apps/desktop/src/components/agents/agent-groups.tsx
@@ -0,0 +1,175 @@
+import { useId, type ReactNode } from "react";
+import { AnimatePresence, motion } from "motion/react";
+import { ArrowUpRight, ChevronRight, Globe } from "lucide-react";
+import type { Agent, Workspace } from "@godmode/shared";
+import { AgentAvatar } from "@/components/common";
+import { WorkspaceTile } from "@/components/workspaces/workspace-tile";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+import { useUi } from "@/stores/ui";
+import { cn } from "@/lib/utils";
+
+export interface AgentGroup {
+ /** "global" or the workspace id. */
+ key: string;
+ workspace: Workspace | null;
+ agents: T[];
+}
+
+/** Agents by workspace: global first, then in the sidebar's workspace order. Keeps the order of `agents` inside a group. */
+export function groupByWorkspace(agents: T[], workspaces: Workspace[]): AgentGroup[] {
+ const order = new Map(workspaces.map((w, i) => [w.id, i]));
+ const groups = new Map>();
+ for (const a of agents) {
+ const key = a.workspaceId ?? "global";
+ const g = groups.get(key) ?? { key, workspace: a.workspaceId ? (workspaces.find((w) => w.id === a.workspaceId) ?? null) : null, agents: [] };
+ g.agents.push(a);
+ groups.set(key, g);
+ }
+ const rank = (g: AgentGroup) => (g.key === "global" ? -1 : (order.get(g.key) ?? Number.MAX_SAFE_INTEGER));
+ return [...groups.values()].sort((a, b) => rank(a) - rank(b) || (a.workspace?.name ?? "").localeCompare(b.workspace?.name ?? ""));
+}
+
+export function groupLabel(group: { key: string; workspace: Workspace | null }) {
+ return group.key === "global" ? "Global" : (group.workspace?.name ?? "Workspace");
+}
+
+export function GroupIcon({ workspace, isGlobal, className }: { workspace: Workspace | null; isGlobal: boolean; className?: string }) {
+ if (isGlobal || !workspace)
+ return (
+
+
+
+ );
+ return ;
+}
+
+/**
+ * One workspace's agents under a heading that folds them away. The heading sums up who is working and who needs you,
+ * and shows the faces when folded so you still see who is in there. `panel` frames it as a card (the org chart).
+ */
+export function WorkspaceSection({
+ group,
+ working,
+ needsYou,
+ count,
+ panel,
+ label: title,
+ icon,
+ forceOpen,
+ aside,
+ className,
+ children,
+}: {
+ group: AgentGroup;
+ working: number;
+ needsYou: number;
+ /** Shown as the size of the group; defaults to its agents. */
+ count?: number;
+ panel?: boolean;
+ /** Override the workspace's name and icon (a team inside one workspace). */
+ label?: string;
+ icon?: ReactNode;
+ /** Open whatever was folded, e.g. while a search runs, so no hit hides. */
+ forceOpen?: boolean;
+ /** Extra line under the heading, e.g. who the group reports to. */
+ aside?: ReactNode;
+ className?: string;
+ children: ReactNode;
+}) {
+ const folded = useUi((s) => s.collapsedAgentGroups.includes(group.key));
+ const collapsed = folded && !forceOpen;
+ const toggle = useUi((s) => s.toggleAgentGroup);
+ const setWorkspace = useUi((s) => s.setWorkspace);
+ const bodyId = useId();
+ const label = title ?? groupLabel(group);
+ const total = count ?? group.agents.length;
+ const faces = group.agents.slice(0, 5);
+ return (
+
+
+
+ toggle(group.key)}
+ aria-expanded={!collapsed}
+ aria-controls={collapsed ? undefined : bodyId}
+ className="-ml-1 flex min-w-0 items-center gap-2 rounded-md py-0.5 pr-1.5 pl-1 text-left transition hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
+ >
+
+ {icon ?? }
+ {label}
+ {total}
+
+
+
+
+ {collapsed && (
+
+ {faces.map((a, i) => (
+ 0 && "-ml-1.5")} />
+ ))}
+ {group.agents.length > faces.length && +{group.agents.length - faces.length} }
+
+ )}
+
+ {group.workspace && (
+
+
+ setWorkspace(group.workspace!.id)}
+ >
+
+
+
+ Show only {label}
+
+ )}
+
+
+ {!collapsed && (
+
+ {aside}
+ {children}
+
+ )}
+
+
+ );
+}
+
+function GroupStats({ working, needsYou }: { working: number; needsYou: number }) {
+ if (!working && !needsYou) return null;
+ return (
+
+ {working > 0 && (
+
+
+ {working} working
+
+ )}
+ {needsYou > 0 && (
+
+
+ {needsYou} need{needsYou === 1 ? "s" : ""} you
+
+ )}
+
+ );
+}
diff --git a/apps/desktop/src/components/agents/agent-list.tsx b/apps/desktop/src/components/agents/agent-list.tsx
new file mode 100644
index 0000000..5a57c9d
--- /dev/null
+++ b/apps/desktop/src/components/agents/agent-list.tsx
@@ -0,0 +1,118 @@
+import { Link } from "react-router";
+import { formatDistanceToNowStrict } from "date-fns";
+import { History, MessageSquare, Workflow } from "lucide-react";
+import type { Agent } from "@godmode/shared";
+import { leadOf } from "@godmode/shared";
+import { AgentAvatar } from "@/components/common";
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { AgentStatus, useAgentLiveRun, useAgentMood, useStartAgentChat } from "./agent-actions";
+import { AgentMenu } from "./agent-card";
+
+const COLUMNS = "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 @2xl:grid-cols-[minmax(0,1fr)_minmax(0,11rem)_6.5rem_auto] @5xl:grid-cols-[minmax(0,1fr)_minmax(0,12rem)_minmax(0,11rem)_3.5rem_6.5rem_auto]";
+
+/** Agents as dense rows: many at a glance, one line each. */
+export function AgentList({
+ agents,
+ all,
+ routineCounts,
+ onRunTask,
+ onDelete,
+}: {
+ agents: Agent[];
+ /** Every agent, to name leads. */
+ all: Agent[];
+ routineCounts: Map;
+ onRunTask: (agent: Agent) => void;
+ onDelete: (agent: Agent) => void;
+}) {
+ return (
+
+ {agents.map((a) => (
+
+ ))}
+
+ );
+}
+
+function AgentRow({
+ agent,
+ all,
+ routineCount,
+ onRunTask,
+ onDelete,
+}: {
+ agent: Agent;
+ all: Agent[];
+ routineCount: number;
+ onRunTask: (agent: Agent) => void;
+ onDelete: (agent: Agent) => void;
+}) {
+ const mood = useAgentMood(agent);
+ const live = useAgentLiveRun(agent.id);
+ const chat = useStartAgentChat();
+ const chatting = chat.isPending && chat.variables?.agent.id === agent.id;
+ const lead = leadOf(agent, all);
+ return (
+
+
+
+
+
+
+ {agent.name}
+
+ {agent.isDefault && Built-in }
+
+
+ {agent.role || agent.description || No role yet }
+
+
+
+
+
+
+ {agent.isDefault ? (
+ "Reports to you"
+ ) : lead ? (
+ <>
+
+ {lead.name}
+ >
+ ) : (
+ "—"
+ )}
+
+
+ {routineCount}
+
+
+
+ {agent.lastRunAt ? formatDistanceToNowStrict(new Date(agent.lastRunAt), { addSuffix: true }) : "Never"}
+
+
+
+
+ chat.mutate(agent)}
+ disabled={chatting}
+ >
+ {chatting ? : }
+
+
+ New chat
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/components/agents/org-chart.tsx b/apps/desktop/src/components/agents/org-chart.tsx
index 409359d..3cb7af0 100644
--- a/apps/desktop/src/components/agents/org-chart.tsx
+++ b/apps/desktop/src/components/agents/org-chart.tsx
@@ -1,22 +1,33 @@
+import { useLayoutEffect, useRef, useState } from "react";
import { Link } from "react-router";
-import { Globe, Plus } from "lucide-react";
-import type { Agent, TeamGroup, TeamNode } from "@godmode/shared";
+import { Plus, Users } from "lucide-react";
+import type { Agent, TeamNode } from "@godmode/shared";
import { leadOf, presenceLabel, teamTree } from "@godmode/shared";
import { AgentAvatar } from "@/components/common";
import { useWorkspaces } from "@/lib/hooks";
import { cn } from "@/lib/utils";
import { AgentStatus, useAgentMood, useAgentPresence } from "./agent-actions";
+import { WorkspaceSection, type AgentGroup } from "./agent-groups";
+
+interface Panel {
+ group: AgentGroup;
+ roots: TeamNode[];
+ /** Title and face for a team panel (one workspace, split by lead). */
+ lead?: Agent;
+}
/**
- * The team as a tree: the built-in agent on top, everyone under their lead, with role and live status. Nested lists,
- * so the hierarchy reads the same to a screen reader and every control on a node is a normal tab stop. Agents that
- * don't match the search stay in place, dimmed, so the shape of the team doesn't jump around.
+ * The team as a chart: the built-in agent on top, then a panel per workspace (or, inside one workspace, per team) with
+ * everyone under their lead. Nested lists, so the hierarchy reads the same to a screen reader and every control on a
+ * node is a normal tab stop. Agents that don't match the search stay in place, dimmed, so the shape doesn't jump.
*/
export function OrgChart({
shown,
all,
byWorkspace,
matches,
+ stats,
+ forceOpen,
}: {
/** The agents of the current scope and filter. */
shown: Agent[];
@@ -25,18 +36,79 @@ export function OrgChart({
byWorkspace: boolean;
/** Search hits; null = no search. */
matches: Set | null;
+ stats: (agents: Agent[]) => { working: number; needsYou: number };
+ forceOpen?: boolean;
}) {
+ const { data: workspaces = [] } = useWorkspaces();
const groups = teamTree(shown, all, { byWorkspace });
+ let hub: TeamNode | null = null;
+ for (const g of groups) {
+ const i = g.roots.findIndex((n) => n.agent.isDefault);
+ if (i < 0) continue;
+ hub = g.roots[i]!;
+ g.roots = [...hub.reports, ...g.roots.filter((_, j) => j !== i)];
+ }
+
+ const order = new Map(workspaces.map((w, i) => [w.id, i]));
+ const panels: Panel[] = [];
+ if (byWorkspace) {
+ for (const g of groups) {
+ if (!g.roots.length) continue;
+ const key = g.workspaceId ?? "global";
+ panels.push({ group: { key, workspace: workspaces.find((w) => w.id === g.workspaceId) ?? null, agents: g.roots.flatMap(flatten) }, roots: g.roots });
+ }
+ panels.sort((a, b) => rank(a, order) - rank(b, order));
+ } else {
+ const roots = groups.flatMap((g) => g.roots);
+ const direct = new Set(hub?.reports.map((n) => n.agent.id));
+ const loose = roots.filter((n) => !n.reports.length && direct.has(n.agent.id));
+ // Their lead is filtered out, or they lead each other in a loop.
+ const others = roots.filter((n) => !n.reports.length && !direct.has(n.agent.id));
+ const teams = roots.filter((n) => n.reports.length);
+ if (loose.length) panels.push({ group: { key: "org:direct", workspace: null, agents: loose.map((n) => n.agent) }, roots: loose });
+ if (others.length) panels.push({ group: { key: "org:others", workspace: null, agents: others.map((n) => n.agent) }, roots: others });
+ for (const t of teams) panels.push({ group: { key: `org:team:${t.agent.id}`, workspace: null, agents: flatten(t) }, roots: [t], lead: t.agent });
+ }
+
const onlyBuiltin = all.length === 1 && all[0]!.isDefault;
+ const [ref, width] = useWidth();
+ const columns = balance(panels, Math.min(panels.length, width >= 1100 ? 3 : width >= 720 ? 2 : 1));
return (
-
- {groups.map((g) => (
-
1} all={all} matches={matches} />
- ))}
+
+ {hub &&
leadOf(a, all)?.id === hub!.agent.id)} />}
+ {panels.length > 0 && (
+
+ {columns.map((col, c) => (
+
+ {col.map((p) => {
+ const s = stats(p.group.agents);
+ return (
+
: byWorkspace ? undefined : p.group.key === "org:direct" && hub ?
:
}
+ >
+
+ {p.roots.map((n) => (
+
+ ))}
+
+
+ );
+ })}
+
+ ))}
+
+ )}
{onlyBuiltin && (
@@ -52,90 +124,125 @@ export function OrgChart({
);
}
-function Group({ group, titled, all, matches }: { group: TeamGroup; titled: boolean; all: Agent[]; matches: Set | null }) {
- const { data: workspaces = [] } = useWorkspaces();
- const ws = group.workspaceId ? workspaces.find((w) => w.id === group.workspaceId) : null;
- const label = group.workspaceId ? (ws?.name ?? "Workspace") : "Global";
- const headingId = `org-${group.workspaceId ?? "global"}`;
+/** Panels into columns, each to the shortest so far, in order. Folding a panel doesn't move the others. */
+function balance(panels: Panel[], n: number): Panel[][] {
+ const cols: Panel[][] = Array.from({ length: Math.max(1, n) }, () => []);
+ const heights = cols.map(() => 0);
+ for (const p of panels) {
+ const i = heights.indexOf(Math.min(...heights));
+ cols[i]!.push(p);
+ heights[i]! += 1.5 + p.group.agents.length;
+ }
+ return cols.filter((c) => c.length);
+}
+
+function useWidth() {
+ const ref = useRef(null);
+ const [width, setWidth] = useState(0);
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ setWidth(el.clientWidth);
+ const ro = new ResizeObserver(([e]) => setWidth(e!.contentRect.width));
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+ return [ref, width] as const;
+}
+
+function flatten(n: TeamNode): Agent[] {
+ return [n.agent, ...n.reports.flatMap(flatten)];
+}
+
+function rank(p: Panel, order: Map) {
+ return p.group.key === "global" ? -1 : (order.get(p.group.key) ?? Number.MAX_SAFE_INTEGER);
+}
+
+/** The built-in agent: everyone without a lead of their own reports to it. */
+function Hub({ agent, dim, reports }: { agent: Agent; dim: boolean; reports: Agent[] }) {
+ const mood = useAgentMood(agent);
+ const presence = useAgentPresence(agent);
+ const direct = reports.length;
+ const global = reports.some((a) => !a.workspaceId);
+ const workspaces = new Set(reports.flatMap((a) => (a.workspaceId ? [a.workspaceId] : []))).size;
+ const where = [global && "Global", workspaces > 0 && `${workspaces} workspace${workspaces === 1 ? "" : "s"}`].filter(Boolean).join(" and ");
+ const label = `${agent.name}, ${agent.role || "no role yet"}, ${presenceLabel(presence)}, reports to you`;
return (
-
- {titled && (
-
- {ws ? {ws.icon} : }
- {label}
-
- {group.count} agent{group.count === 1 ? "" : "s"}
-
-
+
+
+
+
+
+
+ {agent.name}
+
+ Built-in
+
+ {agent.role &&
{agent.role}
}
+
+
+
+ {direct > 0 && (
+
+ Leads {direct} agent{direct === 1 ? "" : "s"} directly
+ {(workspaces > 1 || (global && workspaces > 0)) && <>, across {where}>}
+ . Everyone without a lead of their own reports here.
+
)}
-
- {group.roots.map((n) => (
-
- ))}
-
-
+
);
}
-function Branch({
- node,
- depth,
- all,
- matches,
- spread,
- inColumns,
-}: {
- node: TeamNode;
- depth: number;
- all: Agent[];
- matches: Set | null;
- spread?: boolean;
- /** Laid out as a column of its lead's team: no connector on a wide screen. */
- inColumns?: boolean;
-}) {
- const reports = node.reports;
- // On a wide screen the top of a team reads like a chart: each report with a team of its own gets a column, the
- // others stack in one more column.
- const branches = reports.filter((r) => r.reports.length > 0);
- const leaves = reports.filter((r) => r.reports.length === 0);
- const columns = !!spread && depth === 0 && branches.length > 0 && reports.length > 1;
- const sub = (r: TeamNode) => ;
+function Branch({ node, depth, all, matches, last }: { node: TeamNode; depth: number; all: Agent[]; matches: Set | null; last?: boolean }) {
return (
- {depth > 0 && }
-
- {reports.length > 0 &&
- (columns ? (
- 2 ? "@4xl/org:grid-cols-3" : "@4xl/org:grid-cols-2")}>
- {branches.map(sub)}
- {leaves.length > 0 && (
-
-
-
- )}
-
- ) : (
-
- ))}
+ {depth > 0 && (
+ <>
+ {/* Elbow into the row, and the line on to the next sibling. */}
+
+ {!last && }
+ >
+ )}
+
+ {node.reports.length > 0 && (
+
+ {node.reports.map((r, i) => (
+
+ ))}
+
+ )}
);
}
-function NodeCard({ node, all, dim }: { node: TeamNode; all: Agent[]; dim: boolean }) {
+function Row({ node, all, dim }: { node: TeamNode; all: Agent[]; dim: boolean }) {
const agent = node.agent;
const mood = useAgentMood(agent);
const presence = useAgentPresence(agent);
const lead = leadOf(agent, all);
- const label = `${agent.name}, ${agent.role || "no role yet"}, ${presenceLabel(presence)}, reports to ${agent.isDefault ? "you" : (lead?.name ?? "Godmode")}`;
+ const label = `${agent.name}, ${agent.role || "no role yet"}, ${presenceLabel(presence)}, reports to ${lead?.name ?? "Godmode"}`;
+ const team = node.reports.length;
return (
:not([data-line])]:opacity-60",
+ dim && "[&>:not([data-line])]:opacity-35",
+ "[&>*]:transition-opacity",
)}
>
+ {team > 0 &&
}
@@ -143,28 +250,37 @@ function NodeCard({ node, all, dim }: { node: TeamNode
; all: Agent[]; dim
to={`/agents/${agent.id}`}
aria-label={label}
title={agent.name}
- className="truncate text-sm font-medium tracking-[-0.01em] after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none focus-visible:after:ring-[3px] focus-visible:after:ring-ring/50"
+ className="truncate text-sm font-medium tracking-[-0.01em] after:absolute after:inset-0 after:rounded-lg focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring/50"
>
{agent.name}
- {agent.isDefault && Built-in }
+ {team > 0 && (
+
+ Lead · {team}
+
+ )}
- {agent.role && (
-
- {agent.role}
-
- )}
-
+
+ {agent.role || No role yet }
+
{node.leadElsewhere && (
Reports to
{node.leadElsewhere.name}
)}
+ {/* Idle is the norm: just the dot, so whoever is working or needs you stands out. */}
+ {presence.state === "idle" ? (
+
+ Idle
+
+ ) : (
+
+ )}
);
}
diff --git a/apps/desktop/src/pages/agents/agents-page.tsx b/apps/desktop/src/pages/agents/agents-page.tsx
index ee2fd43..67ab860 100644
--- a/apps/desktop/src/pages/agents/agents-page.tsx
+++ b/apps/desktop/src/pages/agents/agents-page.tsx
@@ -1,13 +1,13 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { motion } from "motion/react";
-import { Bot, LayoutGrid, Network, Plus, Search, Sparkles } from "lucide-react";
+import { Bot, LayoutGrid, List, Network, Plus, Search, Sparkles } from "lucide-react";
import type { Agent } from "@godmode/shared";
import { MASCOT_CHARACTER, MASCOT_COLOR } from "@godmode/shared";
import { useAgents, useAllAgents, useMissingLogins, useRoutines, useWorkspaces } from "@/lib/hooks";
import { errorMessage } from "@/lib/api";
import { useLive } from "@/stores/live";
-import { useUi } from "@/stores/ui";
+import { useUi, type AgentsView } from "@/stores/ui";
import { cn } from "@/lib/utils";
import { AgentAvatar, EmptyState, Kbd, PageBody, PageHeader } from "@/components/common";
import { Character } from "@/components/character";
@@ -18,6 +18,8 @@ import { Skeleton } from "@/components/ui/skeleton";
import { AgentCard } from "@/components/agents/agent-card";
import { DeleteAgentDialog, RunTaskDialog } from "@/components/agents/agent-actions";
import { OrgChart } from "@/components/agents/org-chart";
+import { AgentList } from "@/components/agents/agent-list";
+import { groupByWorkspace, WorkspaceSection } from "@/components/agents/agent-groups";
import { TeamTemplates } from "@/components/agents/team-templates";
type Filter = "all" | "running" | "needs" | "scheduled" | "disabled";
@@ -35,8 +37,9 @@ export default function AgentsPage() {
const [params, setParams] = useSearchParams();
const storedView = useUi((s) => s.agentsView);
const setStoredView = useUi((s) => s.setAgentsView);
- const view = params.get("view") === "chart" ? "chart" : params.get("view") === "grid" ? "grid" : storedView;
- const setView = (v: "grid" | "chart") => {
+ const asked = params.get("view");
+ const view: AgentsView = asked === "chart" || asked === "grid" || asked === "list" ? asked : storedView;
+ const setView = (v: AgentsView) => {
setStoredView(v);
setParams((p) => {
const next = new URLSearchParams(p);
@@ -111,6 +114,12 @@ export default function AgentsPage() {
const chartAgents = useMemo(() => agents.filter(passesFilter), [agents, filter, routineCounts, running, missingFor]);
const matches = useMemo(() => (search.trim() ? new Set(visible.map((a) => a.id)) : null), [visible, search]);
+ const stats = (list: Agent[]) => ({ working: list.filter(isRunning).length, needsYou: list.filter(needsYou).length });
+ // In "All workspaces" every view sorts agents under their workspace.
+ const byWorkspace = scope === "all" && agents.some((a) => a.workspaceId);
+ const narrowed = !!search.trim() || filter !== "all";
+ const groups = useMemo(() => (byWorkspace ? groupByWorkspace(visible, workspaces) : []), [byWorkspace, visible, workspaces]);
+
const scopeName =
scope === "all" ? "all workspaces" : scope === "global" ? "the global scope" : (workspaces.find((w) => w.id === scope)?.name ?? "this workspace");
@@ -143,7 +152,7 @@ export default function AgentsPage() {
/>
-
+
(
@@ -192,13 +202,15 @@ export default function AgentsPage() {
key={v.id}
type="button"
aria-pressed={view === v.id}
+ aria-label={v.label}
+ title={v.label}
onClick={() => setView(v.id)}
className={cn(
- "flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm transition focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none [&_svg]:size-3.5",
+ "flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm @7xl:px-2.5 @7xl:py-1 transition focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none [&_svg]:size-3.5",
view === v.id ? "border bg-card text-foreground shadow-card" : "border border-transparent text-muted-foreground hover:text-foreground",
)}
>
- {v.label}
+ {v.label}
))}
@@ -259,7 +271,7 @@ export default function AgentsPage() {
)}
-
a.workspaceId)} matches={matches} />
+
>
) : visible.length === 0 ? (
}
/>
+ ) : view === "list" ? (
+ byWorkspace ? (
+
+ {groups.map((g) => {
+ const st = stats(g.agents);
+ return (
+
+
+
+ );
+ })}
+
+ ) : (
+
+ )
+ ) : byWorkspace ? (
+
+ {groups.map((g) => {
+ const st = stats(g.agents);
+ return (
+
+
+
+ );
+ })}
+
) : (
-
- {visible.map((agent, i) => (
-
-
-
- ))}
-
-
-
-
-
- New agent
-
-
-
+
)}
{/* Just the built-in agent so far: offer a whole team to start with. */}
{!agentsQ.isLoading && allAgents.length > 0 && allAgents.every((a) => a.isDefault) && (
@@ -327,6 +337,44 @@ export default function AgentsPage() {
);
}
+function CardGrid({
+ agents,
+ routineCounts,
+ grouped,
+ onRunTask,
+ onDelete,
+}: {
+ agents: Agent[];
+ routineCounts: Map;
+ /** Under a workspace heading: the card doesn't repeat the workspace, and "New agent" sits in the page header. */
+ grouped?: boolean;
+ onRunTask: (agent: Agent) => void;
+ onDelete: (agent: Agent) => void;
+}) {
+ return (
+
+ {agents.map((agent, i) => (
+
+
+
+ ))}
+ {!grouped && (
+
+
+
+
+
+ New agent
+
+
+ )}
+
+ );
+}
+
/** The team at a glance: every agent's face in a row, watching the pointer. */
function Crew({ agents }: { agents: Agent[] }) {
const shown = agents.slice(0, 8);
diff --git a/apps/desktop/src/stores/ui.ts b/apps/desktop/src/stores/ui.ts
index 44f70c3..a32d436 100644
--- a/apps/desktop/src/stores/ui.ts
+++ b/apps/desktop/src/stores/ui.ts
@@ -2,6 +2,8 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
import { storageKey } from "@/lib/core";
+export type AgentsView = "grid" | "list" | "chart";
+
export type RecentTab = "all" | "running" | "scheduled" | "done";
interface UiState {
@@ -22,8 +24,10 @@ interface UiState {
skippedClaudeVersion: string | null;
/** Task board columns folded to a narrow strip. */
collapsedColumns: string[];
- /** The Agents page shows cards or the org chart. */
- agentsView: "grid" | "chart";
+ /** The Agents page shows cards, rows or the org chart. */
+ agentsView: AgentsView;
+ /** Workspace groups folded on the Agents page ("global" or a workspace id). */
+ collapsedAgentGroups: string[];
/** Which chats the sidebar's Recent list shows. */
recentTab: RecentTab;
/** The human came back after a while: from when to when they were away (Home sums up what happened). Not kept. */
@@ -38,7 +42,8 @@ interface UiState {
setVmPanel: (v: boolean) => void;
skipClaudeVersion: (version: string | null) => void;
toggleColumn: (status: string) => void;
- setAgentsView: (v: "grid" | "chart") => void;
+ setAgentsView: (v: AgentsView) => void;
+ toggleAgentGroup: (key: string) => void;
setRecentTab: (tab: RecentTab) => void;
setAway: (away: { since: string; until: string } | null) => void;
}
@@ -57,6 +62,7 @@ export const useUi = create()(
skippedClaudeVersion: null,
collapsedColumns: ["cancelled"],
agentsView: "grid",
+ collapsedAgentGroups: [],
recentTab: "all",
away: null,
setWorkspace: (workspace) => set({ workspace }),
@@ -73,6 +79,10 @@ export const useUi = create()(
collapsedColumns: s.collapsedColumns.includes(status) ? s.collapsedColumns.filter((c) => c !== status) : [...s.collapsedColumns, status],
})),
setAgentsView: (agentsView) => set({ agentsView }),
+ toggleAgentGroup: (key) =>
+ set((s) => ({
+ collapsedAgentGroups: s.collapsedAgentGroups.includes(key) ? s.collapsedAgentGroups.filter((k) => k !== key) : [...s.collapsedAgentGroups, key],
+ })),
setRecentTab: (recentTab) => set({ recentTab }),
setAway: (away) => set({ away }),
}),
@@ -89,6 +99,7 @@ export const useUi = create()(
skippedClaudeVersion: s.skippedClaudeVersion,
collapsedColumns: s.collapsedColumns,
agentsView: s.agentsView,
+ collapsedAgentGroups: s.collapsedAgentGroups,
recentTab: s.recentTab,
}),
},