Skip to content
Merged
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
187 changes: 109 additions & 78 deletions apps/desktop/src/components/agents/agent-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -71,89 +71,120 @@ export function AgentCard({
<AgentStatus agent={agent} interactive className="mt-0.5 max-w-full" />
</div>
{running && <WorkingTicks count={6} className="mt-1 h-3 shrink-0 text-brand-strong" />}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" className="relative z-10 -mt-1 -mr-1 text-muted-foreground" aria-label={`Actions for ${agent.name}`}>
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => chat.mutate(agent)}>
<MessageSquare /> New chat
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onRunTask(agent)} disabled={!agent.enabled}>
<Play /> Run task…
</DropdownMenuItem>
{running && (
<DropdownMenuItem onClick={() => pause.mutate(agent)}>
<Pause /> Pause
</DropdownMenuItem>
)}
{paused && (
<DropdownMenuItem onClick={() => resume.mutate(agent)} disabled={!agent.enabled}>
<StepForward /> Continue
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => navigate(`/agents/${agent.id}/routines`)}>
<Workflow /> Automations
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate(`/agents/${agent.id}/settings`)}>
<Pencil /> Edit
</DropdownMenuItem>
{!agent.isDefault && (
<DropdownMenuItem onClick={() => duplicate.mutate(agent)} disabled={duplicate.isPending}>
<Copy /> Duplicate
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => toggle.mutate({ id: agent.id, enabled: !agent.enabled })}>
{agent.enabled ? <PowerOff /> : <Power />} {agent.enabled ? "Switch off" : "Switch on"}
</DropdownMenuItem>
{!agent.isDefault && (
<DropdownMenuItem variant="destructive" onClick={() => onDelete(agent)}>
<Trash2 /> Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<AgentMenu agent={agent} chat={chat} onRunTask={onRunTask} onDelete={onDelete} className="-mt-1 -mr-1" />
</div>

<p className="mt-3 line-clamp-2 min-h-10 text-sm text-muted-foreground">
<p className="mt-2.5 line-clamp-2 min-h-10 flex-1 text-[13px] leading-5 text-muted-foreground">
{agent.description || <span className="italic opacity-70">No description</span>}
</p>

<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-muted-foreground">
<ScopeBadge workspaceId={agent.workspaceId} />
<span className="flex items-center gap-1" title="Model">
<Cpu className="size-3.5" /> {agent.model ? modelLabel(agent.model) : "Default model"}
</span>
<span className="flex items-center gap-1" title="Automations">
<Workflow className="size-3.5" /> {routineCount}
</span>
<span className="flex items-center gap-1" title={agent.lastRunAt ? new Date(agent.lastRunAt).toLocaleString() : "Never ran"}>
<History className="size-3.5" />
{agent.lastRunAt ? formatDistanceToNowStrict(new Date(agent.lastRunAt), { addSuffix: true }) : "Never"}
</span>
<div className="mt-3 flex items-center gap-3 border-t pt-3 text-xs text-muted-foreground">
<div className="flex min-w-0 flex-1 items-center gap-x-3 overflow-hidden whitespace-nowrap">
{showScope && <ScopeBadge workspaceId={agent.workspaceId} className="min-w-0 truncate" />}
<span className="flex shrink-0 items-center gap-1" title={`${routineCount} automation${routineCount === 1 ? "" : "s"}`}>
<Workflow className="size-3.5" /> {routineCount}
</span>
<span className="flex min-w-0 items-center gap-1" title={agent.lastRunAt ? `Last run ${new Date(agent.lastRunAt).toLocaleString()}` : "Never ran"}>
<History className="size-3.5 shrink-0" />
<span className="truncate">{agent.lastRunAt ? formatDistanceToNowStrict(new Date(agent.lastRunAt), { addSuffix: true }) : "Never"}</span>
</span>
{agent.model && (
<span className="flex min-w-0 items-center gap-1" title="Model">
<Cpu className="size-3.5 shrink-0" /> <span className="truncate">{modelLabel(agent.model)}</span>
</span>
)}
</div>
<div className="relative z-10 flex shrink-0 gap-1.5">
<Button size="xs" variant="secondary" className="h-7 px-2.5" onClick={() => chat.mutate(agent)} disabled={chatting}>
{chatting ? <Spinner /> : <MessageSquare />} Chat
</Button>
{paused ? (
<Button size="xs" variant="outline" className="h-7 px-2.5" onClick={() => resume.mutate(agent)} disabled={resume.isPending || !agent.enabled}>
{resume.isPending ? <Spinner /> : <StepForward />} Continue
</Button>
) : running ? (
<Button size="xs" variant="outline" className="h-7 px-2.5" onClick={() => pause.mutate(agent)} disabled={pause.isPending}>
{pause.isPending ? <Spinner /> : <Pause className="fill-current" />} Pause
</Button>
) : (
<Button size="xs" variant="outline" className="h-7 px-2.5" onClick={() => onRunTask(agent)} disabled={!agent.enabled}>
<Play /> Run
</Button>
)}
</div>
</div>
</div>
);
}

<div className="relative z-10 mt-4 flex gap-2 border-t pt-3">
<Button size="sm" variant="secondary" className="flex-1" onClick={() => chat.mutate(agent)} disabled={chatting}>
{chatting ? <Spinner /> : <MessageSquare />} 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<typeof useStartAgentChat>;
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" className={cn("relative z-10 text-muted-foreground", className)} aria-label={`Actions for ${agent.name}`}>
<Ellipsis />
</Button>
{paused ? (
<Button size="sm" variant="outline" className="flex-1" onClick={() => resume.mutate(agent)} disabled={resume.isPending || !agent.enabled}>
{resume.isPending ? <Spinner /> : <StepForward />} Continue
</Button>
) : running ? (
<Button size="sm" variant="outline" className="flex-1" onClick={() => pause.mutate(agent)} disabled={pause.isPending}>
{pause.isPending ? <Spinner /> : <Pause className="fill-current" />} Pause
</Button>
) : (
<Button size="sm" variant="outline" className="flex-1" onClick={() => onRunTask(agent)} disabled={!agent.enabled}>
<Play /> Run task
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => chat.mutate(agent)} disabled={chatting}>
<MessageSquare /> New chat
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onRunTask(agent)} disabled={!agent.enabled}>
<Play /> Run task…
</DropdownMenuItem>
{running && (
<DropdownMenuItem onClick={() => pause.mutate(agent)}>
<Pause /> Pause
</DropdownMenuItem>
)}
</div>
</div>
{paused && (
<DropdownMenuItem onClick={() => resume.mutate(agent)} disabled={!agent.enabled}>
<StepForward /> Continue
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => navigate(`/agents/${agent.id}/routines`)}>
<Workflow /> Automations
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate(`/agents/${agent.id}/settings`)}>
<Pencil /> Edit
</DropdownMenuItem>
{!agent.isDefault && (
<DropdownMenuItem onClick={() => duplicate.mutate(agent)} disabled={duplicate.isPending}>
<Copy /> Duplicate
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => toggle.mutate({ id: agent.id, enabled: !agent.enabled })}>
{agent.enabled ? <PowerOff /> : <Power />} {agent.enabled ? "Switch off" : "Switch on"}
</DropdownMenuItem>
{!agent.isDefault && (
<DropdownMenuItem variant="destructive" onClick={() => onDelete(agent)}>
<Trash2 /> Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
175 changes: 175 additions & 0 deletions apps/desktop/src/components/agents/agent-groups.tsx
Original file line number Diff line number Diff line change
@@ -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<T = Agent> {
/** "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<T extends { workspaceId: string | null }>(agents: T[], workspaces: Workspace[]): AgentGroup<T>[] {
const order = new Map(workspaces.map((w, i) => [w.id, i]));
const groups = new Map<string, AgentGroup<T>>();
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<T>) => (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 (
<span aria-hidden className={cn("grid size-6 shrink-0 place-items-center rounded-md bg-secondary text-muted-foreground ring-1 ring-foreground/5 ring-inset", className)}>
<Globe className="size-3.5" />
</span>
);
return <WorkspaceTile icon={workspace.icon} color={workspace.color} size="sm" className={className} />;
}

/**
* 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 (
<section
aria-label={label}
className={cn(panel ? "overflow-hidden rounded-xl border bg-card shadow-card" : "", className)}
>
<div className={cn("group/head flex min-h-9 items-center gap-2", panel ? "h-12 px-3" : !collapsed && "mb-3", panel && !collapsed && "border-b")}>
<h2 className="min-w-0">
<button
type="button"
onClick={() => 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"
>
<ChevronRight aria-hidden className={cn("size-3.5 shrink-0 text-muted-foreground transition-transform duration-200", !collapsed && "rotate-90")} />
{icon ?? <GroupIcon workspace={group.workspace} isGlobal={group.key === "global"} />}
<span className="truncate text-sm font-medium tracking-[-0.01em]">{label}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{total}</span>
</button>
</h2>
<GroupStats working={working} needsYou={needsYou} />
<AnimatePresence initial={false}>
{collapsed && (
<motion.span initial={{ opacity: 0, x: -4 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0 }} aria-hidden className="ml-1 hidden items-center @sm:flex">
{faces.map((a, i) => (
<AgentAvatar key={a.id} agent={a} size="sm" still className={cn("rounded-full ring-2 ring-background", i > 0 && "-ml-1.5")} />
))}
{group.agents.length > faces.length && <span className="ml-1 text-[11px] text-muted-foreground tabular-nums">+{group.agents.length - faces.length}</span>}
</motion.span>
)}
</AnimatePresence>
{group.workspace && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="ml-auto text-muted-foreground opacity-0 transition-opacity group-hover/head:opacity-100 focus-visible:opacity-100"
aria-label={`Open ${label}`}
onClick={() => setWorkspace(group.workspace!.id)}
>
<ArrowUpRight />
</Button>
</TooltipTrigger>
<TooltipContent>Show only {label}</TooltipContent>
</Tooltip>
)}
</div>
<AnimatePresence initial={false}>
{!collapsed && (
<motion.div
id={bodyId}
key="body"
// A panel slides open; cards fade, so their shadows aren't clipped while the height moves.
initial={panel ? { height: 0, opacity: 0 } : { opacity: 0, y: -4 }}
animate={panel ? { height: "auto", opacity: 1 } : { opacity: 1, y: 0 }}
exit={panel ? { height: 0, opacity: 0 } : { opacity: 0, transition: { duration: 0 } }}
transition={{ duration: 0.22, ease: [0.2, 0, 0, 1] }}
className={cn(panel && "overflow-hidden")}
>
{aside}
{children}
</motion.div>
)}
</AnimatePresence>
</section>
);
}

function GroupStats({ working, needsYou }: { working: number; needsYou: number }) {
if (!working && !needsYou) return null;
return (
<span className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{working > 0 && (
<span className="inline-flex items-center gap-1">
<span aria-hidden className="size-1.5 rounded-full bg-brand animate-live-dot" />
{working} working
</span>
)}
{needsYou > 0 && (
<span className="inline-flex items-center gap-1 text-foreground">
<span aria-hidden className="size-1.5 rounded-full bg-warning" />
{needsYou} need{needsYou === 1 ? "s" : ""} you
</span>
)}
</span>
);
}
Loading
Loading