mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
Restore concentric agent dashboard view
This commit is contained in:
parent
a39f79cd25
commit
a2ace3ddfa
3 changed files with 416 additions and 153 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
173
src/renderer/src/components/dashboard/ConcentricView.tsx
Normal file
173
src/renderer/src/components/dashboard/ConcentricView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
src/renderer/src/components/dashboard/model.ts
Normal file
129
src/renderer/src/components/dashboard/model.ts
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue