From e4fbd7a880498d7a575c17600c1b78deccd61a7d Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Tue, 6 Oct 2026 08:35:40 +0200 Subject: [PATCH] Agents page: group by workspace, add a list view, rework the org chart - Grid and new List view sort agents under collapsible workspace sections in All workspaces, with working / needs-you counts and a shortcut to the workspace - Org chart: the built-in agent as the hub on top, a panel per workspace (or per team inside one) in balanced columns, compact rows with tree connectors - Compact agent card footer; the action menu is shared by card and row - Search and filters open folded groups so no hit hides --- .../src/components/agents/agent-card.tsx | 187 +++++++----- .../src/components/agents/agent-groups.tsx | 175 +++++++++++ .../src/components/agents/agent-list.tsx | 118 ++++++++ .../src/components/agents/org-chart.tsx | 282 ++++++++++++------ apps/desktop/src/pages/agents/agents-page.tsx | 122 +++++--- apps/desktop/src/stores/ui.ts | 17 +- 6 files changed, 700 insertions(+), 201 deletions(-) create mode 100644 apps/desktop/src/components/agents/agent-groups.tsx create mode 100644 apps/desktop/src/components/agents/agent-list.tsx diff --git a/apps/desktop/src/components/agents/agent-card.tsx b/apps/desktop/src/components/agents/agent-card.tsx index e094c733..46405f5d 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)} + + )} +
+
+ + {paused ? ( + + ) : running ? ( + + ) : ( + + )} +
+
+ ); +} -
- - {paused ? ( - - ) : running ? ( - - ) : ( - + + + 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 00000000..403a8242 --- /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 ( +
+
+

+ +

+ + + {collapsed && ( + + {faces.map((a, i) => ( + 0 && "-ml-1.5")} /> + ))} + {group.agents.length > faces.length && +{group.agents.length - faces.length}} + + )} + + {group.workspace && ( + + + + + 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 00000000..5a57c9da --- /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"} + + + + + + + New chat + + + +
  • + ); +} diff --git a/apps/desktop/src/components/agents/org-chart.tsx b/apps/desktop/src/components/agents/org-chart.tsx index 409359db..3cb7af0d 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 && ( -
    • -
        {leaves.map(sub)}
      -
    • - )} -
    - ) : ( -
      {reports.map(sub)}
    - ))} + {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 ee2fd43f..67ab8606 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 44f70c37..a32d4364 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, }), },