Restore concentric agent dashboard view

This commit is contained in:
brennanb2025 2026-04-14 16:11:00 -07:00
parent a39f79cd25
commit a2ace3ddfa
3 changed files with 416 additions and 153 deletions

View file

@ -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<typeof buildAgentStatusHoverRows>[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<string, Worktree[]>,
tabsByWorktree: Record<string, TerminalTab[]>,
agentStatusByPaneKey: ReturnType<typeof useAppStore.getState>['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
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[11px] font-medium text-foreground">
{formatAgentTypeLabel(row.agentType)}
{formatRowAgentLabel(row)}
</div>
<div className={cn('truncate text-[10px] text-muted-foreground', isStale && 'opacity-60')}>
{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<typeof buildRepoGroups>
now: number
activeWorktreeId: string | null
onSelectWorktree: (worktreeId: string) => void
}): React.JSX.Element {
return (
<button
type="button"
onClick={onSelect}
className={cn(
'flex w-full flex-col gap-2 rounded-lg border px-3 py-3 text-left transition-colors',
isActive
? 'border-border bg-accent/45'
: 'border-border/50 bg-background hover:bg-accent/25'
)}
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-semibold text-foreground">
{worktree.displayName}
<div className="flex flex-col gap-4">
{repoGroups.map(({ repo, worktrees }) => (
<section key={repo.id} className="flex flex-col gap-2">
<div className="flex items-center gap-2 px-1">
<span
className="size-2 rounded-full"
style={{ backgroundColor: repo.badgeColor }}
aria-hidden="true"
/>
<div className="truncate text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{repo.displayName}
</div>
<div className="text-[10px] text-muted-foreground/70">{worktrees.length}</div>
</div>
<div className="truncate text-[10px] text-muted-foreground">{worktree.branch}</div>
</div>
<div className="inline-flex items-center gap-1 text-[10px] text-muted-foreground">
<span>{rows.length}</span>
<ChevronRight className="size-3" />
</div>
</div>
<div className="flex flex-col gap-1.5">
{rows.map((row) => (
<AgentRow key={row.key} row={row} now={now} />
))}
</div>
</button>
<div className="flex flex-col gap-2">
{worktrees.map(({ worktree, rows }) => (
<button
key={worktree.id}
type="button"
onClick={() => onSelectWorktree(worktree.id)}
className={cn(
'flex w-full flex-col gap-2 rounded-lg border px-3 py-3 text-left transition-colors',
activeWorktreeId === worktree.id
? 'border-border bg-accent/45'
: 'border-border/50 bg-background hover:bg-accent/25'
)}
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-semibold text-foreground">
{worktree.displayName}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{worktree.branch}
</div>
</div>
<div className="inline-flex items-center gap-1 text-[10px] text-muted-foreground">
<span>{rows.length}</span>
<ChevronRight className="size-3" />
</div>
</div>
<div className="flex flex-col gap-1.5">
{rows.map((row) => (
<AgentRow key={row.key} row={row} now={now} />
))}
</div>
</button>
))}
</div>
</section>
))}
</div>
)
}
@ -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<DashboardMode>('concentric')
const now = Date.now()
const repoGroups = useMemo(
@ -196,35 +140,52 @@ export default function AgentDashboard(): React.JSX.Element {
return (
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3 scrollbar-sleek">
<div className="flex flex-col gap-4">
{repoGroups.map(({ repo, worktrees }) => (
<section key={repo.id} className="flex flex-col gap-2">
<div className="flex items-center gap-2 px-1">
<span
className="size-2 rounded-full"
style={{ backgroundColor: repo.badgeColor }}
aria-hidden="true"
/>
<div className="truncate text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{repo.displayName}
</div>
<div className="text-[10px] text-muted-foreground/70">{worktrees.length}</div>
</div>
<div className="flex flex-col gap-2">
{worktrees.map(({ worktree, rows }) => (
<WorktreeCard
key={worktree.id}
worktree={worktree}
rows={rows}
now={now}
isActive={worktree.id === activeWorktreeId}
onSelect={() => setActiveWorktree(worktree.id)}
/>
))}
</div>
</section>
))}
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Agent Dashboard
</div>
<div className="text-[11px] text-muted-foreground">
Lifecycle-first overview across all active worktrees
</div>
</div>
<ToggleGroup
type="single"
value={mode}
onValueChange={(value) => {
if (value === 'concentric' || value === 'list') {
setMode(value)
}
}}
variant="outline"
size="sm"
>
<ToggleGroupItem value="concentric" aria-label="Concentric view">
<Orbit className="mr-1 size-3.5" />
Concentric
</ToggleGroupItem>
<ToggleGroupItem value="list" aria-label="List view">
<Rows3 className="mr-1 size-3.5" />
List
</ToggleGroupItem>
</ToggleGroup>
</div>
{mode === 'concentric' ? (
<ConcentricView
repoGroups={repoGroups}
now={now}
activeWorktreeId={activeWorktreeId}
onSelectWorktree={setActiveWorktree}
/>
) : (
<ListView
repoGroups={repoGroups}
now={now}
activeWorktreeId={activeWorktreeId}
onSelectWorktree={setActiveWorktree}
/>
)}
</div>
)
}

View file

@ -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 (
<div className="mt-2 flex flex-wrap justify-center gap-1">
{rows.slice(0, 6).map((row) => {
const state = getRowState(row, now)
return (
<span
key={row.key}
title={`${formatRowAgentLabel(row)}${state.label}`}
className={cn('size-2 rounded-full', state.dotClassName)}
/>
)
})}
</div>
)
}
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 (
<section className="relative flex min-h-[30rem] items-center justify-center overflow-hidden rounded-[2rem] border border-border/50 bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.06),transparent_58%)] px-4 py-8">
<div className="pointer-events-none absolute inset-0 opacity-60">
{[140, 230, 310].map((size) => (
<div
key={size}
className="absolute left-1/2 top-1/2 rounded-full border border-border/30"
style={{
width: size,
height: size,
transform: 'translate(-50%, -50%)'
}}
/>
))}
</div>
<div
className="relative flex size-40 items-center justify-center rounded-full border text-center shadow-sm"
style={{
background: `radial-gradient(circle at 30% 30%, ${group.repo.badgeColor}33, transparent 58%), var(--background)`,
borderColor: `${group.repo.badgeColor}55`
}}
>
<div className="space-y-1 px-4">
<div
className="mx-auto size-2 rounded-full"
style={{ backgroundColor: group.repo.badgeColor }}
/>
<div className="line-clamp-3 text-[13px] font-semibold leading-tight text-foreground">
{group.repo.displayName}
</div>
<div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
{group.worktrees.length} worktrees
</div>
</div>
</div>
{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 (
<button
key={worktree.id}
type="button"
onClick={() => onSelectWorktree(worktree.id)}
className={cn(
'absolute flex flex-col items-center justify-center rounded-full border px-3 text-center transition-all',
'hover:scale-[1.03] hover:border-foreground/30',
isActive
? 'border-foreground/35 bg-accent/70 shadow-[0_10px_30px_rgba(0,0,0,0.12)]'
: 'border-border/60 bg-background/90 shadow-[0_6px_20px_rgba(0,0,0,0.08)]'
)}
style={{
width: size,
height: size,
left: `calc(50% + ${position.x}px - ${size / 2}px)`,
top: `calc(50% + ${position.y}px - ${size / 2}px)`
}}
>
<div className="line-clamp-2 text-[11px] font-semibold leading-tight text-foreground">
{worktree.displayName}
</div>
<div className="mt-1 truncate text-[9px] text-muted-foreground">
{worktree.branch}
</div>
<div className="mt-1 text-[9px] uppercase tracking-[0.12em] text-muted-foreground/80">
{formatWorktreeStateSummary(rows, now)}
</div>
<AgentDots rows={rows} now={now} />
</button>
)
})
})}
</section>
)
}
export default function ConcentricView({
repoGroups,
now,
activeWorktreeId,
onSelectWorktree
}: {
repoGroups: RepoGroup[]
now: number
activeWorktreeId: string | null
onSelectWorktree: (worktreeId: string) => void
}): React.JSX.Element {
return (
<div className="grid grid-cols-1 gap-5 xl:grid-cols-2">
{repoGroups.map((group) => (
<RepoOrb
key={group.repo.id}
group={group}
now={now}
activeWorktreeId={activeWorktreeId}
onSelectWorktree={onSelectWorktree}
/>
))}
</div>
)
}

View file

@ -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<typeof buildAgentStatusHoverRows>[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<string, Worktree[]>,
tabsByWorktree: Record<string, TerminalTab[]>,
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
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)
}