diff --git a/src/renderer/src/components/dashboard/AgentDashboard.tsx b/src/renderer/src/components/dashboard/AgentDashboard.tsx index 97a076dc..a0fef8ce 100644 --- a/src/renderer/src/components/dashboard/AgentDashboard.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboard.tsx @@ -1,94 +1,15 @@ -import React, { useMemo } from 'react' -import { ChevronRight, Bot } from 'lucide-react' +import React, { useMemo, useState } from 'react' +import { Bot, ChevronRight, Orbit, Rows3 } from 'lucide-react' import { useAppStore } from '@/store' -import { buildAgentStatusHoverRows } from '@/components/sidebar/AgentStatusHover' -import { formatAgentTypeLabel, isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { cn } from '@/lib/utils' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' -import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' +import { buildRepoGroups, formatRowAgentLabel, getRowState } from './model' +import ConcentricView from './ConcentricView' +import type { DashboardRow } from './model' -type DashboardRow = ReturnType[number] - -type WorktreeGroup = { - worktree: Worktree - rows: DashboardRow[] -} - -type RepoGroup = { - repo: Repo - worktrees: WorktreeGroup[] -} - -function isAgentRow(row: DashboardRow): boolean { - return row.kind === 'explicit' || row.agentType !== 'unknown' || row.heuristicState !== null -} - -function compareRows(a: DashboardRow, b: DashboardRow): number { - return b.sortTimestamp - a.sortTimestamp -} - -function getRowState( - row: DashboardRow, - now: number -): { - label: string - className: string -} { - if (row.kind === 'explicit') { - const isFresh = isExplicitAgentStatusFresh(row.explicit, now, AGENT_STATUS_STALE_AFTER_MS) - const state = - !isFresh && row.heuristicState - ? row.heuristicState - : row.explicit.state === 'blocked' - ? 'waiting' - : row.explicit.state - - if (state === 'working') { - return { label: 'Working', className: 'bg-emerald-500/12 text-emerald-700' } - } - if (state === 'waiting' || state === 'permission') { - return { label: 'Waiting', className: 'bg-amber-500/14 text-amber-700' } - } - return { label: 'Done', className: 'bg-zinc-500/12 text-zinc-700' } - } - - if (row.heuristicState === 'working') { - return { label: 'Working', className: 'bg-emerald-500/12 text-emerald-700' } - } - if (row.heuristicState === 'permission') { - return { label: 'Waiting', className: 'bg-amber-500/14 text-amber-700' } - } - return { label: 'Idle', className: 'bg-zinc-500/12 text-zinc-700' } -} - -function buildRepoGroups( - repos: Repo[], - worktreesByRepo: Record, - tabsByWorktree: Record, - agentStatusByPaneKey: ReturnType['agentStatusByPaneKey'], - now: number -): RepoGroup[] { - return repos - .map((repo) => { - const worktrees = - (worktreesByRepo[repo.id] ?? []) - .map((worktree) => { - const rows = buildAgentStatusHoverRows( - tabsByWorktree[worktree.id] ?? [], - agentStatusByPaneKey, - now - ) - .filter(isAgentRow) - .sort(compareRows) - - return rows.length > 0 ? { worktree, rows } : null - }) - .filter((value): value is WorktreeGroup => value !== null) ?? [] - - return worktrees.length > 0 ? { repo, worktrees } : null - }) - .filter((value): value is RepoGroup => value !== null) -} +type DashboardMode = 'concentric' | 'list' function AgentRow({ row, now }: { row: DashboardRow; now: number }): React.JSX.Element { const state = getRowState(row, now) @@ -103,7 +24,7 @@ function AgentRow({ row, now }: { row: DashboardRow; now: number }): React.JSX.E
- {formatAgentTypeLabel(row.agentType)} + {formatRowAgentLabel(row)}
{row.tabTitle} @@ -121,48 +42,70 @@ function AgentRow({ row, now }: { row: DashboardRow; now: number }): React.JSX.E ) } -function WorktreeCard({ - worktree, - rows, - isActive, - onSelect, - now +function ListView({ + repoGroups, + now, + activeWorktreeId, + onSelectWorktree }: { - worktree: Worktree - rows: DashboardRow[] - isActive: boolean - onSelect: () => void + repoGroups: ReturnType now: number + activeWorktreeId: string | null + onSelectWorktree: (worktreeId: string) => void }): React.JSX.Element { return ( - +
+ {worktrees.map(({ worktree, rows }) => ( + + ))} +
+ + ))} +
) } @@ -173,6 +116,7 @@ export default function AgentDashboard(): React.JSX.Element { const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) + const [mode, setMode] = useState('concentric') const now = Date.now() const repoGroups = useMemo( @@ -196,35 +140,52 @@ export default function AgentDashboard(): React.JSX.Element { return (
-
- {repoGroups.map(({ repo, worktrees }) => ( -
-
-
-
- {worktrees.map(({ worktree, rows }) => ( - setActiveWorktree(worktree.id)} - /> - ))} -
-
- ))} +
+
+
+ Agent Dashboard +
+
+ Lifecycle-first overview across all active worktrees +
+
+ { + if (value === 'concentric' || value === 'list') { + setMode(value) + } + }} + variant="outline" + size="sm" + > + + + Concentric + + + + List + +
+ + {mode === 'concentric' ? ( + + ) : ( + + )}
) } diff --git a/src/renderer/src/components/dashboard/ConcentricView.tsx b/src/renderer/src/components/dashboard/ConcentricView.tsx new file mode 100644 index 00000000..8c61cdd6 --- /dev/null +++ b/src/renderer/src/components/dashboard/ConcentricView.tsx @@ -0,0 +1,173 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { formatRowAgentLabel, formatWorktreeStateSummary, getRowState } from './model' +import type { RepoGroup } from './model' + +function bubbleSize(agentCount: number): number { + return Math.max(92, Math.min(132, 88 + agentCount * 8)) +} + +function ringPosition(index: number, total: number, ringIndex: number): { x: number; y: number } { + const radius = 118 + ringIndex * 68 + const angle = (Math.PI * 2 * index) / Math.max(total, 1) - Math.PI / 2 + return { + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius + } +} + +function splitIntoRings(count: number): number[] { + if (count <= 6) { + return [count] + } + if (count <= 14) { + return [6, count - 6] + } + return [6, 8, count - 14] +} + +function AgentDots({ + rows, + now +}: { + rows: RepoGroup['worktrees'][number]['rows'] + now: number +}): React.JSX.Element { + return ( +
+ {rows.slice(0, 6).map((row) => { + const state = getRowState(row, now) + return ( + + ) + })} +
+ ) +} + +function RepoOrb({ + group, + now, + activeWorktreeId, + onSelectWorktree +}: { + group: RepoGroup + now: number + activeWorktreeId: string | null + onSelectWorktree: (worktreeId: string) => void +}): React.JSX.Element { + const ringCounts = splitIntoRings(group.worktrees.length) + let offset = 0 + + return ( +
+
+ {[140, 230, 310].map((size) => ( +
+ ))} +
+ +
+
+
+
+ {group.repo.displayName} +
+
+ {group.worktrees.length} worktrees +
+
+
+ + {ringCounts.map((count, ringIndex) => { + const worktrees = group.worktrees.slice(offset, offset + count) + offset += count + + return worktrees.map(({ worktree, rows }, index) => { + const position = ringPosition(index, count, ringIndex) + const size = bubbleSize(rows.length) + const isActive = worktree.id === activeWorktreeId + + return ( + + ) + }) + })} +
+ ) +} + +export default function ConcentricView({ + repoGroups, + now, + activeWorktreeId, + onSelectWorktree +}: { + repoGroups: RepoGroup[] + now: number + activeWorktreeId: string | null + onSelectWorktree: (worktreeId: string) => void +}): React.JSX.Element { + return ( +
+ {repoGroups.map((group) => ( + + ))} +
+ ) +} diff --git a/src/renderer/src/components/dashboard/model.ts b/src/renderer/src/components/dashboard/model.ts new file mode 100644 index 00000000..3d2ad31a --- /dev/null +++ b/src/renderer/src/components/dashboard/model.ts @@ -0,0 +1,129 @@ +import { buildAgentStatusHoverRows } from '@/components/sidebar/AgentStatusHover' +import { formatAgentTypeLabel, isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' + +export type DashboardRow = ReturnType[number] + +export type WorktreeGroup = { + worktree: Worktree + rows: DashboardRow[] +} + +export type RepoGroup = { + repo: Repo + worktrees: WorktreeGroup[] +} + +function isAgentRow(row: DashboardRow): boolean { + return row.kind === 'explicit' || row.agentType !== 'unknown' || row.heuristicState !== null +} + +function compareRows(a: DashboardRow, b: DashboardRow): number { + return b.sortTimestamp - a.sortTimestamp +} + +export function getRowState( + row: DashboardRow, + now: number +): { + label: string + className: string + dotClassName: string +} { + if (row.kind === 'explicit') { + const isFresh = isExplicitAgentStatusFresh(row.explicit, now, AGENT_STATUS_STALE_AFTER_MS) + const state = + !isFresh && row.heuristicState + ? row.heuristicState + : row.explicit.state === 'blocked' + ? 'waiting' + : row.explicit.state + + if (state === 'working') { + return { + label: 'Working', + className: 'bg-emerald-500/12 text-emerald-700', + dotClassName: 'bg-emerald-500' + } + } + if (state === 'waiting' || state === 'permission') { + return { + label: 'Waiting', + className: 'bg-amber-500/14 text-amber-700', + dotClassName: 'bg-amber-500' + } + } + return { + label: 'Done', + className: 'bg-zinc-500/12 text-zinc-700', + dotClassName: 'bg-zinc-500' + } + } + + if (row.heuristicState === 'working') { + return { + label: 'Working', + className: 'bg-emerald-500/12 text-emerald-700', + dotClassName: 'bg-emerald-500/70' + } + } + if (row.heuristicState === 'permission') { + return { + label: 'Waiting', + className: 'bg-amber-500/14 text-amber-700', + dotClassName: 'bg-amber-500/70' + } + } + return { + label: 'Idle', + className: 'bg-zinc-500/12 text-zinc-700', + dotClassName: 'bg-zinc-400/60' + } +} + +export function buildRepoGroups( + repos: Repo[], + worktreesByRepo: Record, + tabsByWorktree: Record, + agentStatusByPaneKey: Record, + now: number +): RepoGroup[] { + return repos + .map((repo) => { + const worktrees = + (worktreesByRepo[repo.id] ?? []) + .map((worktree) => { + const rows = buildAgentStatusHoverRows( + tabsByWorktree[worktree.id] ?? [], + agentStatusByPaneKey, + now + ) + .filter(isAgentRow) + .sort(compareRows) + + return rows.length > 0 ? { worktree, rows } : null + }) + .filter((value): value is WorktreeGroup => value !== null) ?? [] + + return worktrees.length > 0 ? { repo, worktrees } : null + }) + .filter((value): value is RepoGroup => value !== null) +} + +export function formatWorktreeStateSummary(rows: DashboardRow[], now: number): string { + const waiting = rows.filter((row) => getRowState(row, now).label === 'Waiting').length + const working = rows.filter((row) => getRowState(row, now).label === 'Working').length + if (waiting > 0) { + return `${waiting} waiting` + } + if (working > 0) { + return `${working} working` + } + return `${rows.length} idle` +} + +export function formatRowAgentLabel(row: DashboardRow): string { + return formatAgentTypeLabel(row.agentType) +}