Show child workspace status counts in sidebar cards

Distinguish monitoring agents and refine lineage alignment so workspace
activity is easier to scan.
This commit is contained in:
Jinjing
2026-11-12 23:46:19 -08:00
parent b621e9a844
commit 4dda2e7204
15 changed files with 336 additions and 172 deletions
+15 -5
View File
@@ -1724,15 +1724,25 @@ html.native-shell .app-layout {
--border: color-mix(in oklab, var(--worktree-sidebar-foreground) 5%, var(--worktree-sidebar));
}
/* Experimental 2.0 lineage guide: painted, so it adds no width; a 5px tick reaches each child's title. */
/* Experimental 2.0 lineage guide, painted in the children's original 14px offset; a tick reaches each child. */
.worktree-card-slot-lineage {
--worktree-card-slot-guide: color-mix(
in srgb,
var(--worktree-sidebar-foreground) 16%,
transparent
);
background: linear-gradient(var(--worktree-card-slot-guide), var(--worktree-card-slot-guide))
no-repeat 0 0 / 1px 100%;
position: relative;
}
.worktree-card-slot-lineage::before {
content: '';
position: absolute;
left: -9px;
top: 0;
bottom: 8px;
width: 1px;
background: var(--worktree-card-slot-guide);
pointer-events: none;
}
.worktree-card-slot-lineage > * {
@@ -1742,9 +1752,9 @@ html.native-shell .app-layout {
.worktree-card-slot-lineage > *::before {
content: '';
position: absolute;
left: -3px;
left: -9px;
top: 15px;
width: 5px;
width: 12px;
height: 1px;
background: var(--worktree-card-slot-guide);
pointer-events: none;
@@ -1,4 +1,6 @@
import React from 'react'
import { Activity, CircleX } from 'lucide-react'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
import { translate } from '@/i18n/i18n'
import { formatShortTimeAgo } from '@/lib/short-time-ago'
@@ -20,7 +22,9 @@ function slotLabel(signal: SlotSignal): string | undefined {
? translate('sidebar.cardSlot.markedUnread', 'Marked unread by you')
: translate('sidebar.cardSlot.unread', 'Unread result')
case 'working':
return translate('sidebar.cardSlot.working', 'Working')
return signal.monitoring
? translate('sidebar.cardSlot.monitoring', 'Monitoring')
: translate('sidebar.cardSlot.working', 'Working')
case 'offline':
return translate('sidebar.cardSlot.offlineLabel', 'Host offline')
case 'time':
@@ -70,6 +74,8 @@ export function WorktreeCardSlot({
'h-4 rounded-[5px] bg-agent-question/15 px-1.5 font-semibold text-agent-question-text'
)}
>
{/* Same question glyph tabs, the dashboard and kanban use for an agent waiting on you. */}
<AgentQuestionIcon className="size-2.5 shrink-0" />
{signal.count > 1
? translate('sidebar.cardSlot.needYouCount', '{{count}} need you', {
count: signal.count
@@ -80,6 +86,8 @@ export function WorktreeCardSlot({
case 'failed':
return (
<span {...slotProps} className={cn(base, 'font-semibold text-destructive')}>
{/* A crossed circle, not a red dot, so failed never reads like the blue unread dot. */}
<CircleX className="size-2.5 shrink-0" aria-hidden="true" />
{translate('sidebar.cardSlot.failed', 'Failed')}
</span>
)
@@ -93,7 +101,11 @@ export function WorktreeCardSlot({
case 'working':
return (
<span {...slotProps} className={cn(base, 'text-muted-foreground')}>
<AgentWorkingSpinner className="size-[9px] border-[1.5px]" />
{signal.monitoring ? (
<Activity className="size-2.5 shrink-0 text-agent-working" aria-hidden="true" />
) : (
<AgentWorkingSpinner className="size-[9px] border-[1.5px]" />
)}
{formatSlotTime(signal.at, now)}
</span>
)
@@ -31,7 +31,7 @@ export function SlotAgentLinesHeader({
<button
type="button"
draggable={false}
className="worktree-agent-row-hover mt-[3px] flex h-[19px] w-full min-w-0 items-center gap-1.5 rounded-sm pr-2 pl-1 text-left text-2xs leading-none font-medium text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring"
className="worktree-agent-row-hover mt-[3px] flex h-[19px] w-full min-w-0 items-center gap-1.5 rounded-sm pr-1.5 pl-1 text-left text-2xs leading-none font-medium text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring"
aria-expanded={true}
aria-label={translate(
'auto.components.sidebar.worktree.card.compact.agents.0c1debfe84',
@@ -172,7 +172,7 @@ export function WorktreeCardSlotAgentLines({
}
return (
<div
className="mt-px flex flex-col gap-px"
className="mt-px flex flex-col gap-px pl-2.5"
role={hasLineage ? 'tree' : 'group'}
aria-label={translate('auto.components.sidebar.WorktreeCardAgents.1b0a156717', 'Agents')}
onDoubleClick={stopPropagation}
@@ -1,126 +1,21 @@
import React from 'react'
import { defineFixture } from '../../component-fixtures/component-fixture'
import { useAppStore } from '@/store'
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspaces/folder-workspace-worktree'
import { worktreeCardStyleSettingsUpdate } from '../../../../shared/worktree/card-style'
import WorktreeCard from './workspace-card/surface/WorktreeCard'
import * as agentExpansion from './workspace-card/activity/worktree-card-agents-expansion-state'
import * as rows from './worktree-list/rows/indentation'
import {
MINUTE,
makeWorktree,
seedWorkspaces,
type WorkspaceSeed
} from './worktree-card-slot-fixture-seeds'
const MINUTE = 60_000
const SIDEBAR_WIDTH = 320
// Grouped-by-project geometry from getWorktreeCardContentIndent: one tree step plus the project inset.
const TOP_LEVEL_CONTENT_INDENT = 20
type AgentSeed = {
agentType: string
state: AgentStatusEntry['state']
minutesAgo: number
toolName?: string
toolInput?: string
message?: string
failed?: boolean
unread?: boolean
prompt?: string
}
type WorkspaceSeed = {
id: string
name: string
agents: AgentSeed[]
manualUnread?: boolean
lastActivityMinutesAgo?: number
}
function makeWorktree(seed: WorkspaceSeed, now: number): Worktree {
return {
id: seed.id,
repoId: 'repo-orca',
path: `/repo/orca/${seed.name}`,
displayName: seed.name,
branch: `jinjing/${seed.name}`,
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: seed.manualUnread === true,
isPinned: false,
sortOrder: 0,
lastActivityAt: now - (seed.lastActivityMinutesAgo ?? 30) * MINUTE
}
}
function seedWorkspaces(seeds: readonly WorkspaceSeed[], now: number): void {
agentExpansion.clearWorktreeAgentExpansionStateForTests()
const tabsByWorktree: Record<string, TerminalTab[]> = {}
const agentStatusByPaneKey: Record<string, AgentStatusEntry> = {}
const unreadAgentCompletionPanes: Record<string, 'agent-completion'> = {}
const acknowledged: Record<string, number> = {}
const ptyIdsByTabId: Record<string, string[]> = {}
for (const seed of seeds) {
tabsByWorktree[seed.id] = seed.agents.map((agent, index) => {
const tabId = `${seed.id}-tab-${index}`
const paneKey = `${tabId}:00000000-0000-4000-8000-00000000000${index}`
agentStatusByPaneKey[paneKey] = {
state: agent.state,
prompt: agent.prompt ?? '',
updatedAt: now,
stateStartedAt: now - agent.minutesAgo * MINUTE,
agentType: agent.agentType,
paneKey,
tabId,
worktreeId: seed.id,
stateHistory: [],
...(agent.toolName ? { toolName: agent.toolName, toolInput: agent.toolInput } : {}),
...(agent.message ? { lastAssistantMessage: agent.message } : {}),
...(agent.failed
? { mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: now } }
: {})
}
ptyIdsByTabId[tabId] = [`pty-${tabId}`]
if (agent.unread) {
unreadAgentCompletionPanes[paneKey] = 'agent-completion'
} else {
acknowledged[paneKey] = now - agent.minutesAgo * MINUTE
}
return {
id: tabId,
ptyId: `pty-${tabId}`,
worktreeId: seed.id,
title: agent.agentType,
customTitle: null,
color: null,
sortOrder: index,
createdAt: now - 60 * MINUTE
}
})
}
useAppStore.setState((state) => ({
settings: state.settings
? {
...state.settings,
...worktreeCardStyleSettingsUpdate('experimental-2')
}
: state.settings,
worktreeCardProperties: ['status', 'inline-agents'],
tabsByWorktree: { ...state.tabsByWorktree, ...tabsByWorktree },
ptyIdsByTabId: { ...state.ptyIdsByTabId, ...ptyIdsByTabId },
agentStatusByPaneKey: { ...state.agentStatusByPaneKey, ...agentStatusByPaneKey },
acknowledgedAgentsByPaneKey: { ...state.acknowledgedAgentsByPaneKey, ...acknowledged },
agentStatusEpoch: state.agentStatusEpoch + 1,
unreadAgentCompletionPanes: {
...state.unreadAgentCompletionPanes,
...unreadAgentCompletionPanes
}
}))
}
const FLAT: WorkspaceSeed[] = [
{
id: 'wt-needs',
@@ -334,7 +229,11 @@ function Card({
lineageChildCount={childCount}
lineageCollapsed={collapsed}
lineageDescendantIds={descendantIds}
lineageChildWorktreeIds={descendantIds}
lineageChildren={children}
lineageChildrenStyle={rows.getLineageChildrenInlineStyle(
rows.LINEAGE_CHILDREN_INLINE_OFFSET
)}
onLineageToggle={childCount > 0 ? () => {} : undefined}
/>
</div>
@@ -1,9 +1,7 @@
import React, { useCallback, useMemo } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
import { Button } from '@/components/ui/button'
import { useNow } from '@/hooks/use-now'
import { translate } from '@/i18n/i18n'
import { canConnectSshStatus, isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
@@ -31,6 +29,7 @@ import {
WorktreeCardRenameFailedButton
} from './worktree-card-title-actions'
import { getWorktreeCardHost, WorktreeCardHostName } from './worktree-card-host-name'
import { WorktreeCardChildrenChip } from './worktree-card-children-chip'
import { WorktreeCardSlot } from './WorktreeCardSlot'
import { SlotAgentLinesHeader, WorktreeCardSlotAgentLines } from './WorktreeCardSlotAgentLines'
import { shouldBeginWorktreeRename } from './workspace-card/controller/worktree-card-model'
@@ -107,6 +106,7 @@ export function WorktreeCardSlotContent({
showLineageChildChip,
lineageCollapsed,
lineageDescendantIds,
lineageChildWorktreeIds,
onLineageToggle,
lineageChildAriaLabel,
childWorkspaceShortLabel,
@@ -177,9 +177,11 @@ export function WorktreeCardSlotContent({
{foldsAgents ? (
<>
{agentsExpanded ? (
<SlotAgentLinesHeader label={agentCountLabel} onCollapse={toggleAgents} />
<div className="pl-2.5">
<SlotAgentLinesHeader label={agentCountLabel} onCollapse={toggleAgents} />
</div>
) : (
<div className="mt-[3px] min-w-0 pr-2 pl-1">
<div className="mt-[3px] min-w-0 pr-1.5 pl-3.5">
<CompactAgentSummaryButton
agents={lines.map((line) => line.agent)}
subjectLabel={agentCountLabel}
@@ -194,23 +196,14 @@ export function WorktreeCardSlotContent({
agentLines
)}
{showLineageChildChip ? (
<div className="mt-[3px] flex min-w-0 pl-1">
<Button
type="button"
variant="outline"
size="chip"
className="max-w-40 text-muted-foreground"
aria-label={lineageChildAriaLabel}
aria-expanded={!lineageCollapsed}
onClick={onLineageToggle}
>
{lineageCollapsed ? (
<ChevronRight className="size-2.5" />
) : (
<ChevronDown className="size-2.5" />
)}
<span className="truncate">{childWorkspaceShortLabel}</span>
</Button>
<div className="mt-[3px] flex min-w-0 pr-1.5 pl-3.5">
<WorktreeCardChildrenChip
childWorktreeIds={lineageChildWorktreeIds}
collapsed={lineageCollapsed}
label={childWorkspaceShortLabel}
ariaLabel={lineageChildAriaLabel}
onToggle={onLineageToggle}
/>
</div>
) : null}
</>
@@ -227,7 +220,7 @@ export function WorktreeCardSlotContent({
className="group/worktree-card flex min-w-0 flex-col"
data-worktree-card-hover-trigger=""
>
<div className="flex h-5 min-w-0 items-center gap-1.5 pr-2">
<div className="flex h-5 min-w-0 items-center gap-1.5 pr-1.5">
<WorktreeTitleInlineRename
displayName={visibleCardTitle}
disabled={isDeleting || affiliateListMode}
@@ -259,7 +252,7 @@ export function WorktreeCardSlotContent({
</div>
{showDetailsRow ? (
<div
className="mt-1 mb-1 flex h-4 min-w-0 items-center gap-1.5 pr-2 pl-1"
className="mt-1 mb-1 flex h-4 min-w-0 items-center gap-1.5 pr-1.5 pl-3.5"
data-worktree-card-details-row=""
>
{presentation.hasMetaRow ? (
@@ -108,7 +108,7 @@ export function IdleChildAgentGroupRow({
className={cn(
'worktree-agent-row-hover',
layout === 'slot'
? 'flex h-[19px] w-full min-w-0 items-center gap-1.5 rounded-sm pr-2 pl-1 text-left text-2xs leading-none'
? 'flex h-[19px] w-full min-w-0 items-center gap-1.5 rounded-sm pr-1.5 pl-1 text-left text-2xs leading-none'
: 'flex h-6 min-w-0 items-center gap-1 rounded-sm text-left text-[11px] leading-none',
layout === 'full' && '-ml-1 w-[calc(100%+0.25rem)] pl-1 pr-2',
layout === 'compact' && 'w-full px-1',
@@ -60,7 +60,11 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }):
: undefined
}
)
const parentCardContent = slotLayout ? <WorktreeCardSlotContent card={card} presentation={buildWorktreeCardPresentation(card)} /> : templateContent
const parentCardContent = slotLayout ? (
<WorktreeCardSlotContent card={card} presentation={buildWorktreeCardPresentation(card)} />
) : (
templateContent
)
const { cardStyle } = geometryFor(!slotLayout && frame.statusLane)
const titleOnlyCard = frame.singleLine
@@ -68,19 +72,16 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }):
<div
className={cn(
'relative flex cursor-pointer flex-col transition-[background-color,border-color,opacity,box-shadow] duration-200 outline-none select-none',
// Why: 2.0 rows own their right inset and the surface has no border, so nested slots share one right edge.
slotLayout ? 'py-1.25' : ['pr-1.5', titleOnlyCard ? 'py-2' : 'pt-1.25 pb-1.5'],
'pr-1.5',
titleOnlyCard ? 'py-2' : 'pt-1.25 pb-1.5',
flushSurface ? 'ml-1 w-[calc(100%-0.25rem)]' : 'ml-1',
'overflow-hidden rounded-lg',
// Navigation updates the live attribute before React; it must own the complete active style.
isLineageDropTarget
? [
'bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground ring-1 ring-inset ring-worktree-sidebar-ring/60',
!slotLayout && 'border border-worktree-sidebar-foreground/40'
]
? 'border border-worktree-sidebar-foreground/40 bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground ring-1 ring-inset ring-worktree-sidebar-ring/60'
: isActiveSurface
? !slotLayout && 'border border-transparent'
: [!slotLayout && 'border border-transparent', 'worktree-sidebar-card-hover'],
? 'border border-transparent'
: 'border border-transparent worktree-sidebar-card-hover',
isActiveSurface && isMultiSelected && 'ring-1 ring-worktree-sidebar-ring/35',
revealHighlight && [
'scroll-to-current-workspace-reveal-highlight',
@@ -127,17 +128,14 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }):
parentCardContent
)}
{lineageChildren && slotLayout ? (
// Why: 5px guide + 3px + the child's 4px margin and 2px inset puts child names 14px in.
{lineageChildren ? (
<div
className="worktree-card-slot-lineage mt-1 ml-[5px] space-y-0.5 pl-[3px]"
data-worktree-lineage-children=""
>
{lineageChildren}
</div>
) : lineageChildren ? (
<div
className={cn('space-y-1', newCardStyle ? 'mt-1.5' : 'mt-3')}
className={cn(
'space-y-1',
newCardStyle ? 'mt-1.5' : 'mt-3',
// 2.0 draws a guide in the original offset; it's painted, so the layout is unchanged.
slotLayout && 'worktree-card-slot-lineage'
)}
data-worktree-lineage-children=""
style={lineageChildrenStyle}
>
@@ -0,0 +1,93 @@
import React from 'react'
import { ChevronDown, CircleX } from 'lucide-react'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { useLineageChildCounts, type LineageChildCounts } from './worktree-card-slot-rollup'
const EMPTY_CHILD_IDS: readonly string[] = []
// Most urgent first, matching the slot's priority.
const ORDER: readonly (keyof LineageChildCounts)[] = ['needs', 'failed', 'unread', 'working']
function countLabel(kind: keyof LineageChildCounts, count: number): string {
switch (kind) {
case 'needs':
return translate('sidebar.cardSlot.childNeeds', '{{count}} need you', {
count,
defaultValue_one: '{{count}} needs you'
})
case 'failed':
return translate('sidebar.cardSlot.childFailed', '{{count}} failed', { count })
case 'unread':
return translate('sidebar.cardSlot.childUnread', '{{count}} unread', { count })
case 'working':
return translate('sidebar.cardSlot.childWorking', '{{count}} working', { count })
}
}
// The slot's glyphs, so a count reads the same as the row it stands for.
function CountGlyph({ kind }: { kind: keyof LineageChildCounts }): React.JSX.Element {
switch (kind) {
case 'needs':
return <AgentQuestionIcon className="size-2.5 shrink-0" />
case 'failed':
return <CircleX className="size-2.5 shrink-0 text-destructive" aria-hidden />
case 'working':
return <AgentWorkingSpinner className="size-2 border-[1.5px]" />
case 'unread':
return <span className="size-1.5 shrink-0 rounded-full bg-unread" />
}
}
/** "6 children", then only the children that need attention, so a quiet subtree stays short. */
export function WorktreeCardChildrenChip({
childWorktreeIds = EMPTY_CHILD_IDS,
collapsed,
label,
ariaLabel,
onToggle
}: {
childWorktreeIds?: readonly string[]
collapsed: boolean
label: string
ariaLabel: string
onToggle?: React.MouseEventHandler<HTMLButtonElement>
}): React.JSX.Element {
const counts = useLineageChildCounts(childWorktreeIds)
const shown = ORDER.filter((kind) => counts[kind] > 0)
const description = shown.map((kind) => countLabel(kind, counts[kind])).join(', ')
return (
<Button
type="button"
variant="outline"
size="chip"
className="max-w-full min-w-0 gap-1 text-muted-foreground"
aria-label={description ? `${ariaLabel}. ${description}` : ariaLabel}
aria-expanded={!collapsed}
title={description || undefined}
onClick={onToggle}
>
<ChevronDown
className={cn('size-2.5 shrink-0 transition-transform', collapsed && '-rotate-90')}
aria-hidden
/>
<span className="truncate">{label}</span>
{shown.length > 0 ? (
<span
className="inline-flex shrink-0 items-center gap-1 border-l border-border pl-1"
aria-hidden
>
{shown.map((kind) => (
<span key={kind} className="inline-flex items-center gap-0.5 tabular-nums">
<CountGlyph kind={kind} />
{counts[kind]}
</span>
))}
</span>
) : null}
</Button>
)
}
@@ -188,7 +188,7 @@ export const SlotAgentLineRow = React.memo(function SlotAgentLineRow({
aria-expanded={hasChildren ? childrenExpanded : undefined}
aria-current={view?.current ? 'true' : undefined}
className={cn(
'group/slot-line worktree-agent-row-hover flex h-[19px] w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-sm pr-2 pl-1 text-left text-2xs leading-none outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring',
'group/slot-line worktree-agent-row-hover flex h-[19px] w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-sm pr-1.5 pl-1 text-left text-2xs leading-none outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring',
highlighted && 'bg-worktree-sidebar-accent',
sendTarget?.status === 'sending' && 'cursor-progress opacity-75',
sendTarget?.status === 'disabled' && 'cursor-default opacity-60'
@@ -51,7 +51,7 @@ export function buildSlotAgentSignals(
lineByPaneKey.set(agent.paneKey, {
agent,
dotState,
signal: { kind, at: slotLineAt(agent, kind) }
signal: { kind, at: slotLineAt(agent, kind), monitoring: dotState === 'monitoring' }
})
})
@@ -0,0 +1,116 @@
import { useAppStore } from '@/store'
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { worktreeCardStyleSettingsUpdate } from '../../../../shared/worktree/card-style'
import * as agentExpansion from './workspace-card/activity/worktree-card-agents-expansion-state'
/** Store seeding for the 2.0 card fixtures: workspaces, their agents, and which results are read. */
export const MINUTE = 60_000
type AgentSeed = {
agentType: string
state: AgentStatusEntry['state']
minutesAgo: number
toolName?: string
toolInput?: string
message?: string
failed?: boolean
unread?: boolean
prompt?: string
}
export type WorkspaceSeed = {
id: string
name: string
agents: AgentSeed[]
manualUnread?: boolean
lastActivityMinutesAgo?: number
}
export function makeWorktree(seed: WorkspaceSeed, now: number): Worktree {
return {
id: seed.id,
repoId: 'repo-orca',
path: `/repo/orca/${seed.name}`,
displayName: seed.name,
branch: `jinjing/${seed.name}`,
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: seed.manualUnread === true,
isPinned: false,
sortOrder: 0,
lastActivityAt: now - (seed.lastActivityMinutesAgo ?? 30) * MINUTE
}
}
export function seedWorkspaces(seeds: readonly WorkspaceSeed[], now: number): void {
agentExpansion.clearWorktreeAgentExpansionStateForTests()
const tabsByWorktree: Record<string, TerminalTab[]> = {}
const agentStatusByPaneKey: Record<string, AgentStatusEntry> = {}
const unreadAgentCompletionPanes: Record<string, 'agent-completion'> = {}
const acknowledged: Record<string, number> = {}
const ptyIdsByTabId: Record<string, string[]> = {}
for (const seed of seeds) {
tabsByWorktree[seed.id] = seed.agents.map((agent, index) => {
const tabId = `${seed.id}-tab-${index}`
const paneKey = `${tabId}:00000000-0000-4000-8000-00000000000${index}`
agentStatusByPaneKey[paneKey] = {
state: agent.state,
prompt: agent.prompt ?? '',
updatedAt: now,
stateStartedAt: now - agent.minutesAgo * MINUTE,
agentType: agent.agentType,
paneKey,
tabId,
worktreeId: seed.id,
stateHistory: [],
...(agent.toolName ? { toolName: agent.toolName, toolInput: agent.toolInput } : {}),
...(agent.message ? { lastAssistantMessage: agent.message } : {}),
...(agent.failed
? { mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: now } }
: {})
}
ptyIdsByTabId[tabId] = [`pty-${tabId}`]
if (agent.unread) {
unreadAgentCompletionPanes[paneKey] = 'agent-completion'
} else {
acknowledged[paneKey] = now - agent.minutesAgo * MINUTE
}
return {
id: tabId,
ptyId: `pty-${tabId}`,
worktreeId: seed.id,
title: agent.agentType,
customTitle: null,
color: null,
sortOrder: index,
createdAt: now - 60 * MINUTE
}
})
}
useAppStore.setState((state) => ({
settings: state.settings
? {
...state.settings,
...worktreeCardStyleSettingsUpdate('experimental-2')
}
: state.settings,
worktreeCardProperties: ['status', 'inline-agents'],
tabsByWorktree: { ...state.tabsByWorktree, ...tabsByWorktree },
ptyIdsByTabId: { ...state.ptyIdsByTabId, ...ptyIdsByTabId },
agentStatusByPaneKey: { ...state.agentStatusByPaneKey, ...agentStatusByPaneKey },
acknowledgedAgentsByPaneKey: { ...state.acknowledgedAgentsByPaneKey, ...acknowledged },
agentStatusEpoch: state.agentStatusEpoch + 1,
unreadAgentCompletionPanes: {
...state.unreadAgentCompletionPanes,
...unreadAgentCompletionPanes
}
}))
}
@@ -37,13 +37,15 @@ export function selectCollapsedLineageSlot(
if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
continue
}
const dotState = agentRowDisplayDotState({ state: entry.state, entry })
lines.push({
kind: classifySlotLine({
dotState: agentRowDisplayDotState({ state: entry.state, entry }),
dotState,
needsAttention: false,
unreadCompletion: isAgentSubjectUnread(turnRecords, entry.paneKey)
}),
at: entry.stateStartedAt > 0 ? entry.stateStartedAt : null
at: entry.stateStartedAt > 0 ? entry.stateStartedAt : null,
monitoring: dotState === 'monitoring'
})
}
for (const retained of selectRetainedAgentEntriesForWorktree(state, worktreeId)) {
@@ -107,3 +109,29 @@ export function useLineageLatestAt(descendantIds: readonly string[]): number | n
)
)
}
export type LineageChildCounts = { needs: number; failed: number; unread: number; working: number }
/** Direct children counted once each by their most urgent exception; quiet children aren't counted. */
export function selectLineageChildCounts(
state: AppState,
childIds: readonly string[]
): LineageChildCounts {
const counts: LineageChildCounts = { needs: 0, failed: 0, unread: 0, working: 0 }
for (const childId of childIds) {
const signal = selectCollapsedLineageSlot(state, [childId])
if (signal && signal.kind in counts) {
counts[signal.kind as keyof LineageChildCounts] += 1
}
}
return counts
}
export function useLineageChildCounts(childIds: readonly string[]): LineageChildCounts {
const childKey = childIds.join('\n')
const selector = useCallback(
(state: AppState) => selectLineageChildCounts(state, childKey ? childKey.split('\n') : []),
[childKey]
)
return useAppStore(useShallow(selector))
}
@@ -5,14 +5,15 @@ import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timesta
/** What one agent line means for the Experimental 2.0 card, in slot priority order. */
export type SlotLineKind = 'needs' | 'failed' | 'unread' | 'working' | 'seen' | 'idle'
export type SlotLineSignal = { kind: SlotLineKind; at: number | null }
/** `monitoring`: working, but watching rather than taking turns; drawn with its own glyph. */
export type SlotLineSignal = { kind: SlotLineKind; at: number | null; monitoring?: boolean }
/** One right-hand slot: the first exception that applies, otherwise a time, or quiet. */
export type SlotSignal =
| { kind: 'needs'; count: number }
| { kind: 'failed' }
| { kind: 'unread'; at: number | null; manual: boolean }
| { kind: 'working'; at: number | null }
| { kind: 'working'; at: number | null; monitoring?: boolean }
| { kind: 'offline' }
| { kind: 'time'; at: number | null }
| { kind: 'quiet' }
@@ -93,7 +94,11 @@ export function slotExceptionFor(lines: readonly SlotLineSignal[]): SlotSignal |
}
const working = lines.filter((line) => line.kind === 'working')
if (working.length > 0) {
return { kind: 'working', at: latest(working) }
return {
kind: 'working',
at: latest(working),
monitoring: working.every((line) => line.monitoring === true)
}
}
return null
}
+4
View File
@@ -2531,6 +2531,10 @@
"working_other": "{{count}} working"
}
},
"cardSlot": {
"childNeeds_one": "{{count}} needs you",
"childNeeds_other": "{{count}} need you"
},
"createProject": {
"errors": {
"createDirectoryFailed": "Failed to create directory: {{detail}}",
+7 -1
View File
@@ -259,6 +259,7 @@
"markedUnread": "Marked unread by you",
"unread": "Unread result",
"working": "Working",
"monitoring": "Monitoring",
"offlineLabel": "Host offline",
"offline": "offline",
"needYouCount": "{{count}} need you",
@@ -268,7 +269,12 @@
"detailsRow": "Details row",
"hostRemoved": "host removed",
"disconnected": "disconnected",
"orcaServer": "Orca server"
"orcaServer": "Orca server",
"childNeeds_one": "{{count}} needs you",
"childNeeds_other": "{{count}} need you",
"childFailed": "{{count}} failed",
"childUnread": "{{count}} unread",
"childWorking": "{{count}} working"
}
},
"app": {