Show worktree-attributed child agents inline

- Keep orchestration worker rows visible when hook status arrives before the
  renderer has a tab for the pane.
- Show child agent lineage expanded by default with clearer parent/child styling.
- Tighten worktree list indentation so grouped and nested rows align correctly.
This commit is contained in:
Jinjing
2026-06-01 01:07:47 -07:00
parent bd7f643576
commit d9fba56bda
27 changed files with 817 additions and 285 deletions
+74 -3
View File
@@ -877,6 +877,72 @@
background: color-mix(in srgb, var(--sidebar-foreground) 8%, var(--sidebar));
}
.worktree-agent-lineage-parent-row {
/* Why: inline orchestration trees have two chevron levels; the manager row
needs a structural cue that is quieter than the workspace card chrome. */
background: color-mix(in srgb, var(--sidebar-foreground) 3%, var(--sidebar));
box-shadow:
inset 2px 0 0 color-mix(in srgb, var(--sidebar-foreground) 38%, transparent),
inset 0 0 0 1px color-mix(in srgb, var(--sidebar-foreground) 10%, transparent);
}
.worktree-agent-lineage-parent-row.worktree-agent-row-hover:hover {
background: color-mix(in srgb, var(--sidebar-foreground) 6%, var(--sidebar));
}
.dark .worktree-agent-lineage-parent-row {
background: color-mix(in srgb, var(--accent) 22%, var(--sidebar));
box-shadow:
inset 2px 0 0 color-mix(in srgb, var(--accent) 58%, transparent),
inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
}
.dark .worktree-agent-lineage-parent-row.worktree-agent-row-hover:hover {
background: color-mix(in srgb, var(--accent) 30%, var(--sidebar));
}
.worktree-agent-lineage-child-row {
position: relative;
background: color-mix(in srgb, var(--sidebar) 74%, var(--background));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-foreground) 7%, transparent);
}
.worktree-agent-lineage-child-row.worktree-agent-row-hover:hover {
background: color-mix(in srgb, var(--sidebar-foreground) 4%, var(--sidebar));
}
.worktree-agent-lineage-child-row::before {
content: '';
position: absolute;
left: 0.15rem;
top: 50%;
width: 0.35rem;
border-top: 1px solid color-mix(in srgb, var(--sidebar-foreground) 20%, transparent);
}
.dark .worktree-agent-lineage-child-row {
background: color-mix(in srgb, var(--accent) 10%, var(--sidebar));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 16%, transparent);
}
.dark .worktree-agent-lineage-child-row.worktree-agent-row-hover:hover {
background: color-mix(in srgb, var(--accent) 18%, var(--sidebar));
}
.dark .worktree-agent-lineage-child-row::before {
border-top-color: color-mix(in srgb, var(--accent) 42%, transparent);
}
.worktree-agent-lineage-children {
margin-left: 0.75rem;
padding-left: 0.25rem;
border-left: 1px solid color-mix(in srgb, var(--sidebar-foreground) 22%, transparent);
}
.dark .worktree-agent-lineage-children {
border-left-color: color-mix(in srgb, var(--accent) 28%, transparent);
}
.worktree-agent-send-target-button {
/* Why: the row remains an ordinary agent row during send-target mode; the
action itself carries the AI target outline so the clickable affordance
@@ -898,11 +964,12 @@
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 50%, transparent);
}
/* Why: the virtualized card row is height-measured; animating height would
make TanStack reposition sibling cards on every frame. */
/* Why: the virtualized card row is height-measured; animate the grid track
instead of explicit height so the reveal stays CSS-owned and interruptible. */
.compact-agent-expansion-grid {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
.compact-agent-expansion-grid-expanded {
@@ -922,10 +989,14 @@
}
.compact-agent-expansion-grid-expanded .compact-agent-expansion-content {
animation: compact-agent-expansion-reveal 150ms ease-out both;
animation: compact-agent-expansion-reveal 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.compact-agent-expansion-grid {
transition-duration: 0ms;
}
.compact-agent-expansion-grid-expanded .compact-agent-expansion-content {
animation-duration: 0ms;
}
@@ -50,7 +50,7 @@ export function DashboardAgentChildDisclosure({
onClick={handleToggleChildren}
onMouseDown={stopMouseDown}
onKeyDown={stopKeyDown}
className="-ml-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-sidebar-accent hover:text-foreground"
className="-ml-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded-sm border border-sidebar-border/80 bg-sidebar text-foreground/80 shadow-xs hover:bg-sidebar-accent hover:text-foreground"
aria-label={`${childAgentsExpanded ? 'Hide' : 'Show'} ${childAgentCount} child ${
childAgentCount === 1 ? 'agent' : 'agents'
}`}
@@ -349,4 +349,25 @@ describe('DashboardAgentRow', () => {
expect(classTokens(markup)).toContain('left-[13px]')
expect(markup).toContain('aria-level="1"')
})
it('marks child-disclosure rows as lineage manager rows', () => {
const markup = renderToStaticMarkup(
<TooltipProvider>
<DashboardAgentRow
agent={makeAgent()}
onDismiss={vi.fn()}
onActivate={vi.fn()}
now={NOW}
hideIdentityIcon
hideExpand
childAgentCount={2}
childAgentsExpanded={false}
onToggleChildAgents={vi.fn()}
/>
</TooltipProvider>
)
expect(classTokens(markup)).toContain('worktree-agent-lineage-parent-row')
expect(markup).toContain('aria-label="Show 2 child agents"')
})
})
@@ -296,6 +296,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
// their hover wash must stay softer than the parent card highlight.
// The focused-pane state reuses the same class via data attribute.
'cursor-pointer rounded-sm worktree-agent-row-hover',
hasChildDisclosure && 'worktree-agent-lineage-parent-row',
isLineageChild && 'worktree-agent-lineage-child-row',
sendTargetStatus === 'sending' && 'cursor-progress opacity-75',
sendTargetStatus === 'disabled' && 'cursor-default opacity-60'
)}
@@ -607,7 +607,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
// aligned with the pre-inset layout and the repo header hierarchy.
const cardStyle = flushSurface
? {
paddingLeft: contentIndent > 0 ? `calc(${contentIndent}px - 0.125rem)` : '0.125rem'
paddingLeft:
contentIndent > 0 ? `max(0.125rem, calc(${contentIndent}px - 0.625rem))` : '0.125rem'
}
: contentIndent > 0
? { paddingLeft: `calc(0.125rem + ${contentIndent}px)` }
@@ -674,7 +675,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
const cardBody = (
<div
className={cn(
'group relative flex items-start gap-1.5 px-1.5 pt-1.5 pb-2 cursor-pointer transition-[background-color,border-color,opacity,box-shadow] duration-200 outline-none select-none',
'group relative flex items-start gap-0.5 pl-0 pr-1.5 pt-1.5 pb-2 cursor-pointer transition-[background-color,border-color,opacity,box-shadow] duration-200 outline-none select-none',
flushSurface ? 'ml-1 w-[calc(100%-0.25rem)]' : 'ml-1',
isMultiSelected ? 'rounded-sm' : 'rounded-lg',
isActiveSurface
@@ -725,7 +726,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
) : null}
{/* Content area */}
<div className="flex-1 min-w-0 overflow-hidden flex flex-col gap-1.5">
<div
className={cn(
'flex min-w-0 flex-1 flex-col gap-1.5',
lineageChildren ? 'overflow-visible' : 'overflow-hidden'
)}
>
{/* Header row: Title */}
<div className="flex items-center justify-between min-w-0 gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
@@ -977,7 +983,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
</div>
)}
{lineageChildren && <div className="-ml-3 mt-1.5 space-y-1">{lineageChildren}</div>}
{lineageChildren && (
<div className="-ml-[1.125rem] mt-1.5 w-[calc(100%+1.125rem)] space-y-1">
{lineageChildren}
</div>
)}
</div>
</div>
)
@@ -3,6 +3,7 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
type MockAgentOptions = {
paneKey?: string
@@ -182,7 +183,7 @@ describe('WorktreeCardAgents', () => {
expect(markup).toContain('data-pane-key="tab-1:2"')
})
it('collapses orchestration child agent rows behind a parent disclosure by default', async () => {
it('shows orchestration child agent rows under their parent by default', async () => {
mockAgentActivityDisplayMode = 'full'
mockAgents = [
mockAgent({
@@ -213,9 +214,9 @@ describe('WorktreeCardAgents', () => {
expect(markup).toContain('role="tree"')
expect(markup).toContain('data-pane-key="tab-parent:1"')
expect(markup).not.toContain('data-pane-key="tab-child:1"')
expect(markup).toContain('aria-label="Show 1 child agent"')
expect(markup).toContain('aria-expanded="false"')
expect(markup).toContain('data-pane-key="tab-child:1"')
expect(markup).toContain('aria-label="Hide 1 child agent"')
expect(markup).toContain('aria-expanded="true"')
})
it('keeps partially cyclic orchestration rows visible as flat roots', async () => {
@@ -283,6 +284,9 @@ describe('WorktreeCardAgents', () => {
expect(markup).toContain('All 2 agents done')
expect(markup).toContain('Expand All 2 agents done')
expect(markup).not.toContain('title="Codex done"')
expect(markup).not.toContain('title="Claude done"')
expect(markup).not.toContain('>2 done<')
expect(markup).not.toContain('First agent')
expect(markup).not.toContain('Second agent')
expect(markup).not.toContain('data-testid="agent-row"')
@@ -365,6 +369,9 @@ describe('WorktreeCardAgents', () => {
expect(markup).not.toContain('-space-x-1')
expect(markup).toContain('3 agents: 1 waiting, 1 working, 1 done')
expect(markup).toContain('Expand 3 agents: 1 waiting, 1 working, 1 done')
expect(markup).not.toContain('title="Codex waiting"')
expect(markup).not.toContain('title="Claude working"')
expect(markup).not.toContain('title="Gemini done"')
expect(markup).not.toContain('data-testid="agent-row"')
})
@@ -413,10 +420,51 @@ describe('WorktreeCardAgents', () => {
const markup = renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
const iconTitles = [...markup.matchAll(/title="([^"]+)"/g)].map((match) => match[1])
expect(iconTitles).toEqual(['Codex', 'Gemini', 'Claude'])
expect(iconTitles).toEqual([])
expect(markup).not.toContain('>5 working<')
expect(markup).toContain('>+2<')
})
it('uses a neutral compact summary label while expanded', async () => {
const { CompactAgentSummaryButton } = await import('./worktree-card-compact-agents')
const agents = [
['tab-1:1', 'codex', 'One'],
['tab-1:2', 'codex', 'Two'],
['tab-1:3', 'codex', 'Three'],
['tab-1:4', 'gemini', 'Four'],
['tab-1:5', 'claude', 'Five']
].map(([paneKey, agentType, prompt]) =>
mockAgent({ paneKey, agentType, startedAt: 1000, prompt })
) as DashboardAgentRowData[]
const markup = renderToStaticMarkup(
<CompactAgentSummaryButton
agents={agents}
subjectLabel="5 agents"
expanded
onToggle={vi.fn()}
/>
)
expect(markup).toContain('aria-expanded="true"')
expect(markup).toContain('Collapse 5 agents')
expect(markup).toContain('>5 agents<')
expect(markup).not.toContain('>+2<')
expect(markup).not.toContain('Expand All 5 agents working')
})
it('can slightly indent expanded compact summary content', async () => {
const { CompactAgentExpansion } = await import('./worktree-card-compact-agents')
const markup = renderToStaticMarkup(
<CompactAgentExpansion expanded contentClassName="pl-1">
<div>Agent row</div>
</CompactAgentExpansion>
)
expect(markup).toContain('compact-agent-expansion-content flex flex-col gap-0.5 pt-0.5 pl-1')
})
it('summarizes compact lineage by parent rows before revealing children', async () => {
mockAgentActivityDisplayMode = 'compact'
mockAgents = [
@@ -466,6 +514,9 @@ describe('WorktreeCardAgents', () => {
expect(markup).toContain('role="tree"')
expect(markup).toContain('3 parents: 1 waiting, 1 working, 1 done')
expect(markup).not.toContain('title="Gemini waiting"')
expect(markup).not.toContain('title="Codex working"')
expect(markup).not.toContain('title="Codex done"')
expect(markup).not.toContain('Parent A')
expect(markup).not.toContain('Child A')
})
@@ -255,6 +255,13 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
scrollToBottomIfOutputSinceLastView: true
})
} else {
const liveEntry = useAppStore.getState().agentStatusByPaneKey[paneKey]
if (liveEntry?.worktreeId === worktreeId) {
// Why: orchestration worker status can be worktree-attributed before
// the renderer knows its tab. Keep the visible live row instead of
// dismissing it as stale just because it cannot be focused yet.
return
}
dismissStaleAgentRowByKey(paneKey)
}
},
@@ -270,7 +277,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
[agents]
)
const hasLineage = childrenByParentPaneKey.size > 0
const [expandedLineageParents, setExpandedLineageParents] = useState<ReadonlySet<string>>(
const [collapsedLineageParents, setCollapsedLineageParents] = useState<ReadonlySet<string>>(
() => new Set()
)
const [compactRootListExpanded, setCompactRootListExpanded] = useState(false)
@@ -285,7 +292,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
}, [agentActivityDisplayMode, compactRootListExpanded])
const toggleLineageParent = useCallback((paneKey: string) => {
dispatchSuppressScrollAdjustment()
setExpandedLineageParents((current) => {
setCollapsedLineageParents((current) => {
const next = new Set(current)
if (next.has(paneKey)) {
next.delete(paneKey)
@@ -320,7 +327,9 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
}
const childAgents = childrenByParentPaneKey.get(agent.paneKey) ?? []
const hasChildAgents = childAgents.length > 0
const expanded = expandedLineageParents.has(agent.paneKey)
// Why: spawned child agents are actionable work, so they should be visible
// as soon as the parent appears; the disclosure remains available to fold noise.
const expanded = !collapsedLineageParents.has(agent.paneKey)
const sendTarget = isAgentSendTargetModeActive
? (sendTargetsByPaneKey.get(agent.paneKey) ?? {
status: 'disabled' as const,
@@ -371,11 +380,13 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
// chevron-shifted column and read as floating fragments.
hideLineageConnectors
/>
{hasChildAgents && expanded
? childAgents.map((childAgent) =>
{hasChildAgents && expanded ? (
<div className="worktree-agent-lineage-children">
{childAgents.map((childAgent) =>
renderAgentBranch(childAgent, descendantAncestorPaneKeys)
)
: null}
)}
</div>
) : null}
</React.Fragment>
)
}
@@ -389,7 +400,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
}
const childAgents = childrenByParentPaneKey.get(agent.paneKey) ?? []
const hasChildAgents = childAgents.length > 0
const expanded = expandedLineageParents.has(agent.paneKey)
const expanded = !collapsedLineageParents.has(agent.paneKey)
const descendantAncestorPaneKeys = new Set(ancestorPaneKeys)
descendantAncestorPaneKeys.add(agent.paneKey)
return (
@@ -408,9 +419,11 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
/>
{hasChildAgents ? (
<CompactAgentExpansion expanded={expanded}>
{childAgents.map((childAgent) =>
renderCompactAgentBranch(childAgent, descendantAncestorPaneKeys)
)}
<div className="worktree-agent-lineage-children flex flex-col gap-0.5">
{childAgents.map((childAgent) =>
renderCompactAgentBranch(childAgent, descendantAncestorPaneKeys)
)}
</div>
</CompactAgentExpansion>
) : null}
</React.Fragment>
@@ -451,7 +464,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
{!shouldUseSummaryRow ? (
rootAgents.map((rootAgent) => renderCompactAgentBranch(rootAgent))
) : shouldUseSummaryRow ? (
<CompactAgentExpansion expanded={compactRootListExpanded}>
<CompactAgentExpansion expanded={compactRootListExpanded} contentClassName="pl-1">
{rootAgents.map((rootAgent) => renderCompactAgentBranch(rootAgent))}
</CompactAgentExpansion>
) : null}
@@ -191,8 +191,26 @@ function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage {
}
}
function setLineageFixtureState(groupBy: 'none' | 'repo' = 'none'): void {
const repo = makeRepo()
function setLineageFixtureState(
groupBy: 'none' | 'repo' = 'none',
options: { projectGrouped?: boolean } = {}
): void {
const projectGroup: ProjectGroup = {
id: 'project-group-1',
name: 'Personal',
parentPath: '/tmp/lineage-order',
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const repo = {
...makeRepo(),
projectGroupId: options.projectGrouped ? projectGroup.id : null
}
const parent = makeWorktree({
id: 'parent',
instanceId: 'parent-instance',
@@ -233,6 +251,7 @@ function setLineageFixtureState(groupBy: 'none' | 'repo' = 'none'): void {
pendingRevealWorktree: null,
prCache: {},
prVisibleRefreshGeneration: 0,
projectGroups: options.projectGrouped ? [projectGroup] : [],
ptyIdsByTabId: {},
reorderRepos: vi.fn(),
reportVisibleGitHubPRRefreshCandidates: vi.fn(),
@@ -415,4 +434,16 @@ describe('WorktreeList lineage child card renderer', () => {
'<section data-worktree-card-id="parent" data-content-indent="18" data-flush-surface="true">'
)
})
it('adds project group depth to workspace card content indentation', async () => {
setLineageFixtureState('repo', { projectGrouped: true })
const markup = await renderWorktreeListMarkup()
const parentRow = markup.match(/<div[^>]*id="worktree-list-option-parent"[^>]*>/)?.[0] ?? ''
expect(parentRow).not.toContain('padding-left')
expect(markup).toContain(
'<section data-worktree-card-id="parent" data-content-indent="36" data-flush-surface="true">'
)
})
})
@@ -188,6 +188,11 @@ import {
type WorktreeSectionActivityState,
type WorktreeSectionActivitySummary
} from './worktree-section-activity'
import {
SIDEBAR_TREE_INDENT,
getProjectGroupHeaderPaddingLeft,
getWorktreeCardContentIndent
} from './worktree-list-indentation'
export {
getScrollTopToRevealBounds,
@@ -333,12 +338,7 @@ function getWorktreeVisibilityMenuLabel(repo: Repo): string {
return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees'
}
const LINEAGE_INDENT = 18
// Why: top-level worktrees are children of their project header; indent the
// group one step so the status dots nest under the folder icon for hierarchy.
const WORKTREE_GROUP_INDENT = 18
const PROJECT_GROUP_HEADER_BASE_PADDING = 4
const PROJECT_GROUP_HEADER_INDENT = 10
const LINEAGE_INDENT = SIDEBAR_TREE_INDENT
const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4
type VirtualizedWorktreeViewportProps = {
@@ -2549,9 +2549,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
row.repo && 'overflow-hidden'
)}
style={{
paddingLeft:
PROJECT_GROUP_HEADER_BASE_PADDING +
Math.min(projectGroupDepth, 6) * PROJECT_GROUP_HEADER_INDENT
paddingLeft: getProjectGroupHeaderPaddingLeft(projectGroupDepth)
}}
onDragOver={
isPinnedHeader
@@ -2862,10 +2860,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
// Why: child cards render inside the parent card body, so their
// first nested level starts flush with that inset.
const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth
// Why: grouped rows still indent their contents under the header,
// but the card surface spans the full sidebar hit/background row.
const basePadding = !nested && groupBy !== 'none' ? WORKTREE_GROUP_INDENT : 0
const paddingLeft = basePadding + paddingDepth * LINEAGE_INDENT
// Why: grouped rows inherit their project/group header depth,
// while the card surface still spans the full hit/background row.
const paddingLeft = getWorktreeCardContentIndent({
isGrouped: !nested && groupBy !== 'none',
groupDepth: itemRow.groupDepth,
lineageDepth: paddingDepth
})
const worktreeDragGroupKey = groupKeyByWorktreeId.get(itemRow.worktree.id)
const worktreeDragGroupIndex = groupIndexByWorktreeId.get(itemRow.worktree.id)
const revealHighlightTone =
@@ -2966,11 +2967,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
}
const lineageToggleGroupKey = child.lineageGroupKey
const childContentIndent = Math.max(0, child.depth - 1) * LINEAGE_INDENT
return (
<div
key={child.worktree.id}
style={{ paddingLeft: `${Math.max(0, child.depth - 1) * LINEAGE_INDENT}px` }}
>
<div key={child.worktree.id}>
<WorktreeContextMenu
worktree={child.worktree}
selectedWorktrees={selectedWorktrees}
@@ -2984,7 +2983,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
data-worktree-card-surface="true"
data-worktree-card-active={isActive ? 'true' : undefined}
className={cn(
'relative flex cursor-pointer items-start gap-1.5 rounded-md border border-transparent px-2 py-1.5 transition-colors',
'relative flex w-full cursor-pointer items-start gap-1.5 rounded-lg border border-transparent py-1.5 pr-2 transition-colors',
highlightedRevealWorktreeId === child.worktree.id && [
'scroll-to-current-workspace-reveal-highlight',
revealHighlightTone === 'ai' &&
@@ -3000,88 +2999,97 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
onClick={handleClick}
onDoubleClick={(event) => event.stopPropagation()}
>
<span className="mt-[2px] flex w-4 shrink-0 justify-center pt-[2px]">
<WorktreeActivityStatusIndicator worktreeId={child.worktree.id} />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] leading-tight text-foreground">
{child.worktree.displayName}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5">
{child.repo && groupBy !== 'repo' ? (
<span className="flex h-[16px] shrink-0 items-center gap-1.5 rounded-[4px] border border-border bg-accent px-1.5 text-[10px] font-semibold leading-none text-foreground dark:bg-accent/50 dark:border-border/60">
<RepoBadgeMark color={child.repo.badgeColor} />
<span className="max-w-[6rem] truncate lowercase">
{child.repo.displayName}
</span>
</span>
) : null}
<span className="truncate text-[10.5px] leading-none text-muted-foreground">
{branchDisplayName(child.worktree.branch)}
</span>
</div>
{child.worktree.linkedIssue || child.worktree.comment ? (
<div className="mt-1.5 truncate text-[10.5px] leading-tight text-muted-foreground">
{child.worktree.linkedIssue ? (
<span className="font-medium text-foreground/80">
#{child.worktree.linkedIssue}
<div
className="flex min-w-0 flex-1 items-start gap-1.5 pl-2"
style={
childContentIndent > 0
? { paddingLeft: `calc(0.5rem + ${childContentIndent}px)` }
: undefined
}
>
<span className="mt-[2px] flex w-4 shrink-0 justify-center pt-[2px]">
<WorktreeActivityStatusIndicator worktreeId={child.worktree.id} />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] leading-tight text-foreground">
{child.worktree.displayName}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5">
{child.repo && groupBy !== 'repo' ? (
<span className="flex h-[16px] shrink-0 items-center gap-1.5 rounded-[4px] border border-border bg-accent px-1.5 text-[10px] font-semibold leading-none text-foreground dark:bg-accent/50 dark:border-border/60">
<RepoBadgeMark color={child.repo.badgeColor} />
<span className="max-w-[6rem] truncate lowercase">
{child.repo.displayName}
</span>
</span>
) : null}
{child.worktree.linkedIssue && child.worktree.comment ? ' ' : null}
{child.worktree.comment}
<span className="truncate text-[10.5px] leading-none text-muted-foreground">
{branchDisplayName(child.worktree.branch)}
</span>
</div>
) : null}
{showInlineAgentCards ? (
// Why: nested lineage children use this lightweight
// renderer instead of WorktreeCard, so their inline
// agent rows must be mounted here explicitly.
<WorktreeCardAgents
worktreeId={child.worktree.id}
className="mt-1 divide-y-0"
/>
) : null}
{child.lineageChildCount > 0 && lineageToggleGroupKey ? (
<div className="mt-1.5 flex min-w-0 justify-start">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="h-[18px] max-w-[8rem] gap-1 rounded-md border border-sidebar-border bg-sidebar px-1.5 text-[10px] font-medium leading-none text-muted-foreground shadow-none hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-1 focus-visible:ring-sidebar-ring"
aria-label={`${child.lineageCollapsed ? 'Show' : 'Hide'} ${
child.lineageChildCount
} child ${
child.lineageChildCount === 1 ? 'workspace' : 'workspaces'
}`}
aria-expanded={!child.lineageCollapsed}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleGroupWithScrollAnchor(lineageToggleGroupKey)
}}
>
<Workflow className="size-2.5" />
<span className="truncate">
{child.lineageChildCount}{' '}
{child.lineageChildCount === 1 ? 'child' : 'children'}
</span>
<ChevronDown
className={cn(
'size-2.5 transition-transform',
child.lineageCollapsed && '-rotate-90'
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{child.lineageCollapsed
? 'Show child workspaces'
: 'Hide child workspaces'}
</TooltipContent>
</Tooltip>
</div>
) : null}
{child.worktree.linkedIssue || child.worktree.comment ? (
<div className="mt-1.5 truncate text-[10.5px] leading-tight text-muted-foreground">
{child.worktree.linkedIssue ? (
<span className="font-medium text-foreground/80">
#{child.worktree.linkedIssue}
</span>
) : null}
{child.worktree.linkedIssue && child.worktree.comment ? ' ' : null}
{child.worktree.comment}
</div>
) : null}
{showInlineAgentCards ? (
// Why: nested lineage children use this lightweight
// renderer instead of WorktreeCard, so their inline
// agent rows must be mounted here explicitly.
<WorktreeCardAgents
worktreeId={child.worktree.id}
className="mt-1 divide-y-0"
/>
) : null}
{child.lineageChildCount > 0 && lineageToggleGroupKey ? (
<div className="mt-1.5 flex min-w-0 justify-start">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="h-[18px] max-w-[8rem] gap-1 rounded-md border border-sidebar-border bg-sidebar px-1.5 text-[10px] font-medium leading-none text-muted-foreground shadow-none hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-1 focus-visible:ring-sidebar-ring"
aria-label={`${child.lineageCollapsed ? 'Show' : 'Hide'} ${
child.lineageChildCount
} child ${
child.lineageChildCount === 1 ? 'workspace' : 'workspaces'
}`}
aria-expanded={!child.lineageCollapsed}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleGroupWithScrollAnchor(lineageToggleGroupKey)
}}
>
<Workflow className="size-2.5" />
<span className="truncate">
{child.lineageChildCount}{' '}
{child.lineageChildCount === 1 ? 'child' : 'children'}
</span>
<ChevronDown
className={cn(
'size-2.5 transition-transform',
child.lineageCollapsed && '-rotate-90'
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{child.lineageCollapsed
? 'Show child workspaces'
: 'Hide child workspaces'}
</TooltipContent>
</Tooltip>
</div>
) : null}
</div>
</div>
</div>
</WorktreeContextMenu>
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import type {
AgentStatusEntry,
MigrationUnsupportedPtyEntry
@@ -10,13 +10,6 @@ import {
type FocusedAgentRowHighlightState
} from './focused-agent-row-highlight'
vi.mock('@/lib/agent-status', () => ({
isExplicitAgentStatusFresh: vi.fn(
(entry: AgentStatusEntry, now: number, staleAfterMs: number) =>
now - entry.updatedAt <= staleAfterMs
)
}))
const WORKTREE_ID = 'repo-1::/worktree'
const OTHER_WORKTREE_ID = 'repo-1::/other'
const TAB_ID = 'tab-1'
@@ -89,14 +82,26 @@ function makeState(
}
describe('getFocusedAgentPaneKeyForWorktree', () => {
it('returns the focused pane key when that pane has a fresh live agent status', () => {
it('returns the focused pane key when that pane has a live agent status', () => {
const state = makeState({
agentStatusByPaneKey: {
[PANE_KEY]: makeAgentStatusEntry(PANE_KEY)
}
})
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBe(PANE_KEY)
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID)).toBe(PANE_KEY)
})
it('highlights a focused row whose status has decayed past freshness', () => {
// Why: the inline card keeps decayed agents visible; clicking one must still
// color the row even though its status is no longer fresh.
const state = makeState({
agentStatusByPaneKey: {
[PANE_KEY]: makeAgentStatusEntry(PANE_KEY, 1)
}
})
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID)).toBe(PANE_KEY)
})
it('does not return another split pane in the same terminal tab', () => {
@@ -106,7 +111,7 @@ describe('getFocusedAgentPaneKeyForWorktree', () => {
}
})
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBeNull()
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID)).toBeNull()
})
it('does not highlight while another surface type is active', () => {
@@ -117,7 +122,7 @@ describe('getFocusedAgentPaneKeyForWorktree', () => {
}
})
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBeNull()
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID)).toBeNull()
})
it('returns retained agent row pane keys for the focused pane', () => {
@@ -134,7 +139,7 @@ describe('getFocusedAgentPaneKeyForWorktree', () => {
}
})
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBe(PANE_KEY)
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID)).toBe(PANE_KEY)
})
it('returns migration-unsupported agent row pane keys for the focused pane', () => {
@@ -144,6 +149,6 @@ describe('getFocusedAgentPaneKeyForWorktree', () => {
}
})
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBe(PANE_KEY)
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID)).toBe(PANE_KEY)
})
})
@@ -1,10 +1,5 @@
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id'
export type FocusedAgentRowHighlightState = Pick<
@@ -21,8 +16,7 @@ export type FocusedAgentRowHighlightState = Pick<
export function getFocusedAgentPaneKeyForWorktree(
state: FocusedAgentRowHighlightState,
worktreeId: string,
now = Date.now()
worktreeId: string
): string | null {
if (state.activeWorktreeId !== worktreeId || state.activeTabType !== 'terminal') {
return null
@@ -46,8 +40,11 @@ export function getFocusedAgentPaneKeyForWorktree(
}
const activePaneKey = makePaneKey(activeTabId, activeLeafId)
const liveEntry = state.agentStatusByPaneKey[activePaneKey]
if (liveEntry && isFreshLiveAgent(liveEntry, now)) {
// Why: the inline card lists every agent attributed to this worktree, even
// after its status decays to idle. Highlight whichever displayed row matches
// the focused pane — gating on freshness left clicked-into stale rows with no
// selection coloring.
if (state.agentStatusByPaneKey[activePaneKey]) {
return activePaneKey
}
@@ -64,7 +61,3 @@ export function getFocusedAgentPaneKeyForWorktree(
export function useFocusedAgentPaneKey(worktreeId: string): string | null {
return useAppStore((state) => getFocusedAgentPaneKeyForWorktree(state, worktreeId))
}
function isFreshLiveAgent(entry: AgentStatusEntry, now: number): boolean {
return isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
}
@@ -110,6 +110,31 @@ describe('buildWorktreeAgentRows', () => {
expect(working?.state).toBe('idle')
expect(done?.state).toBe('done')
})
it('renders live worktree-attributed entries even when their tab is absent', () => {
const rows = buildWorktreeAgentRows({
tabs: [],
entries: [
makeEntry(PANE_KEY_1, 1000, {
state: 'working',
worktreeId: 'wt-1',
tabId: 'tab-1',
prompt: 'child agent'
})
],
retained: [],
now: 2000
})
expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY_1])
expect(rows[0]).toMatchObject({
state: 'working',
tab: {
id: 'tab-1',
worktreeId: 'wt-1'
}
})
})
})
describe('applyAgentRowLineage', () => {
@@ -129,6 +129,26 @@ describe('selectLiveAgentStatusEntriesForWorktree', () => {
expect(secondWt2).not.toBe(firstWt2)
expect(secondWt2[0]?.prompt).toBe('updated prompt preview')
})
it('uses worktree attribution when the status tab is not in the renderer tab list', () => {
const childEntry = makeEntry(PANE_KEY_1, 1000, {
state: 'working',
worktreeId: 'wt-1',
tabId: 'tab-1'
})
const state = {
tabsByWorktree: {
'wt-1': []
},
agentStatusByPaneKey: {
[PANE_KEY_1]: childEntry
},
migrationUnsupportedByPtyId: {},
retainedAgentsByPaneKey: {}
}
expect(selectLiveAgentStatusEntriesForWorktree(state, 'wt-1')).toEqual([childEntry])
})
})
describe('selectRetainedAgentEntriesForWorktree', () => {
@@ -90,7 +90,7 @@ function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map<string, Ag
if (!parsed) {
continue
}
const worktreeId = tabIdToWorktreeId.get(parsed.tabId)
const worktreeId = tabIdToWorktreeId.get(parsed.tabId) ?? entry.worktreeId
if (!worktreeId) {
continue
}
@@ -9,6 +9,23 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
import { buildTitleDerivedAgentRows } from './worktree-title-derived-agent-rows'
function tabFromAttributedStatusEntry(entry: AgentStatusEntry): TerminalTab | null {
const parsed = parsePaneKey(entry.paneKey)
if (!parsed || !entry.worktreeId) {
return null
}
return {
id: parsed.tabId,
ptyId: null,
worktreeId: entry.worktreeId,
title: entry.terminalTitle ?? 'Agent',
customTitle: null,
color: null,
sortOrder: Number.MAX_SAFE_INTEGER,
createdAt: entry.stateStartedAt
}
}
export function buildWorktreeAgentRows(args: {
tabs: TerminalTab[]
entries: AgentStatusEntry[]
@@ -56,6 +73,32 @@ export function buildWorktreeAgentRows(args: {
rows.push(...buildTitleDerivedAgentRows({ ...args, seenPaneKeys }))
// Why: orchestration workers can be attributed to a worktree by main before
// their tab is present in this renderer. Keep those live rows visible in the
// worktree card instead of waiting for tab membership that may never arrive.
for (const entry of args.entries) {
if (seenPaneKeys.has(entry.paneKey)) {
continue
}
const tab = tabFromAttributedStatusEntry(entry)
if (!tab) {
continue
}
const isFresh = isExplicitAgentStatusFresh(entry, args.now, AGENT_STATUS_STALE_AFTER_MS)
const shouldDecay =
!isFresh &&
(entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting')
rows.push({
paneKey: entry.paneKey,
entry,
tab,
agentType: entry.agentType ?? 'unknown',
state: shouldDecay ? 'idle' : entry.state,
startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt
})
seenPaneKeys.add(entry.paneKey)
}
for (const ra of args.retained) {
if (seenPaneKeys.has(ra.entry.paneKey)) {
continue
@@ -0,0 +1,135 @@
import type { AgentDotState } from '@/components/AgentStateDot'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
export type SummaryAgentGroup = {
state: AgentDotState
agents: DashboardAgentRowData[]
}
const SUMMARY_STATE_ORDER: AgentDotState[] = [
'waiting',
'blocked',
'interrupted',
'working',
'done',
'idle'
]
function asDotState(state: AgentStatusState | 'idle'): AgentDotState {
switch (state) {
case 'working':
case 'blocked':
case 'waiting':
case 'done':
case 'idle':
return state
}
return 'idle'
}
export function getAgentDotState(agent: DashboardAgentRowData): AgentDotState {
return agent.entry.interrupted === true ? 'interrupted' : asDotState(agent.state)
}
export function formatSummaryStateLabel(state: AgentDotState): string {
switch (state) {
case 'waiting':
return 'waiting'
case 'blocked':
return 'blocked'
case 'interrupted':
return 'interrupted'
case 'working':
return 'working'
case 'done':
return 'done'
case 'idle':
return 'idle'
case 'permission':
return 'needs attention'
}
}
export function buildSummaryAgentGroups(agents: DashboardAgentRowData[]): SummaryAgentGroup[] {
const groups = new Map<AgentDotState, DashboardAgentRowData[]>()
for (const agent of agents) {
const dotState = getAgentDotState(agent)
const group = groups.get(dotState)
if (group) {
group.push(agent)
} else {
groups.set(dotState, [agent])
}
}
return SUMMARY_STATE_ORDER.flatMap((state) => {
const groupAgents = groups.get(state)
return groupAgents ? [{ state, agents: groupAgents }] : []
})
}
export function formatSummaryGroupLabel(group: SummaryAgentGroup): string {
const label = formatSummaryStateLabel(group.state)
return group.agents.length === 1 ? label : `${group.agents.length} ${label}`
}
export function summarizeAgents(agents: DashboardAgentRowData[], subjectLabel: string): string {
const counts = new Map<AgentDotState, number>()
for (const agent of agents) {
const dotState = getAgentDotState(agent)
counts.set(dotState, (counts.get(dotState) ?? 0) + 1)
}
const parts = SUMMARY_STATE_ORDER.flatMap((state) => {
const count = counts.get(state) ?? 0
if (count === 0) {
return []
}
const label = formatSummaryStateLabel(state)
return `${count} ${label}`
})
if (parts.length === 1) {
const onlyStatusLabel = parts[0].replace(/^\d+\s+/, '')
return agents.length === 1
? `${subjectLabel} ${onlyStatusLabel}`
: `All ${subjectLabel} ${onlyStatusLabel}`
}
return `${subjectLabel}: ${parts.join(', ')}`
}
export function summarizeAgentIdentities(agents: DashboardAgentRowData[]): string {
return agents
.map((agent) => {
const agentLabel = formatAgentTypeLabel(agent.agentType)
const stateLabel = formatSummaryStateLabel(getAgentDotState(agent))
return `${agentLabel} ${stateLabel}`
})
.join('; ')
}
export function selectSummaryGroupIconAgents(
agents: DashboardAgentRowData[],
maxCount: number
): DashboardAgentRowData[] {
const groups = new Map<string, { agents: DashboardAgentRowData[]; firstIndex: number }>()
agents.forEach((agent, index) => {
const key = agent.agentType ?? 'unknown'
const group = groups.get(key)
if (group) {
group.agents.push(agent)
} else {
groups.set(key, { agents: [agent], firstIndex: index })
}
})
const sortedGroups = [...groups.values()].sort(
(a, b) => b.agents.length - a.agents.length || a.firstIndex - b.firstIndex
)
const selected: DashboardAgentRowData[] = []
for (const group of sortedGroups) {
if (selected.length >= maxCount) {
break
}
selected.push(group.agents[0])
}
return selected
}
@@ -1,31 +1,21 @@
import React, { useCallback, useRef } from 'react'
import { ChevronRight } from 'lucide-react'
import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot'
import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import { cn } from '@/lib/utils'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import CommentMarkdown from './CommentMarkdown'
import {
buildSummaryAgentGroups,
getAgentDotState,
selectSummaryGroupIconAgents,
summarizeAgentIdentities,
summarizeAgents
} from './worktree-card-agent-summary'
const MARKDOWN_IMAGE_PATTERN = /!\[[^\]\n]*\]\([^)]+\)/
function asDotState(state: AgentStatusState | 'idle'): AgentDotState {
switch (state) {
case 'working':
case 'blocked':
case 'waiting':
case 'done':
case 'idle':
return state
}
return 'idle'
}
function getAgentDotState(agent: DashboardAgentRowData): AgentDotState {
return agent.entry.interrupted === true ? 'interrupted' : asDotState(agent.state)
}
function formatShortTimeAgo(ts: number, now: number): string {
const delta = now - ts
if (delta < 60_000) {
@@ -86,15 +76,6 @@ function getCompactAgentTime(agent: DashboardAgentRowData, now: number): string
return startedAt > 0 ? formatShortTimeAgo(startedAt, now) : null
}
const SUMMARY_STATE_ORDER: AgentDotState[] = [
'waiting',
'blocked',
'interrupted',
'working',
'done',
'idle'
]
function stopActivationKeyPropagation(e: React.KeyboardEvent): void {
// Why: the surrounding worktree list handles Enter/Space as row activation.
// Focused nested buttons need those keys to stay local.
@@ -103,77 +84,6 @@ function stopActivationKeyPropagation(e: React.KeyboardEvent): void {
}
}
function summarizeAgents(agents: DashboardAgentRowData[], subjectLabel: string): string {
const counts = new Map<AgentDotState, number>()
for (const agent of agents) {
const dotState = getAgentDotState(agent)
counts.set(dotState, (counts.get(dotState) ?? 0) + 1)
}
const parts = SUMMARY_STATE_ORDER.flatMap((state) => {
const count = counts.get(state) ?? 0
if (count === 0) {
return []
}
const label =
state === 'waiting'
? 'waiting'
: state === 'blocked'
? 'blocked'
: state === 'interrupted'
? 'interrupted'
: state === 'working'
? 'working'
: state === 'done'
? 'done'
: 'idle'
return `${count} ${label}`
})
if (parts.length === 1) {
const onlyStatusLabel = parts[0].replace(/^\d+\s+/, '')
return agents.length === 1
? `${subjectLabel} ${onlyStatusLabel}`
: `All ${subjectLabel} ${onlyStatusLabel}`
}
return `${subjectLabel}: ${parts.join(', ')}`
}
function selectSummaryIconAgents(
agents: DashboardAgentRowData[],
maxCount: number
): DashboardAgentRowData[] {
const groups = new Map<string, { agents: DashboardAgentRowData[]; firstIndex: number }>()
agents.forEach((agent, index) => {
const key = agent.agentType ?? 'unknown'
const group = groups.get(key)
if (group) {
group.agents.push(agent)
} else {
groups.set(key, { agents: [agent], firstIndex: index })
}
})
const sortedGroups = [...groups.values()].sort(
(a, b) => b.agents.length - a.agents.length || a.firstIndex - b.firstIndex
)
const selected: DashboardAgentRowData[] = []
for (const group of sortedGroups) {
if (selected.length >= maxCount) {
break
}
selected.push(group.agents[0])
}
// Why: once every visible agent kind is represented, duplicate slots should
// reflect the largest groups instead of arbitrary list order.
for (const group of sortedGroups) {
for (const agent of group.agents.slice(1)) {
if (selected.length >= maxCount) {
return selected
}
selected.push(agent)
}
}
return selected
}
type CompactAgentSummaryButtonProps = {
agents: DashboardAgentRowData[]
subjectLabel: string
@@ -183,11 +93,13 @@ type CompactAgentSummaryButtonProps = {
type CompactAgentExpansionProps = {
expanded: boolean
contentClassName?: string
children: React.ReactNode
}
export function CompactAgentExpansion({
expanded,
contentClassName,
children
}: CompactAgentExpansionProps): React.JSX.Element {
const hasRenderedChildrenRef = useRef(expanded)
@@ -209,7 +121,12 @@ export function CompactAgentExpansion({
>
<div className="min-h-0 overflow-hidden">
{shouldRenderChildren && (
<div className="compact-agent-expansion-content flex flex-col gap-0.5 pt-0.5">
<div
className={cn(
'compact-agent-expansion-content flex flex-col gap-0.5 pt-0.5',
contentClassName
)}
>
{children}
</div>
)}
@@ -225,8 +142,12 @@ export function CompactAgentSummaryButton({
onToggle
}: CompactAgentSummaryButtonProps): React.JSX.Element {
const summary = summarizeAgents(agents, subjectLabel)
const iconAgents = selectSummaryIconAgents(agents, 3)
const hiddenIconAgentCount = Math.max(0, agents.length - iconAgents.length)
const groups = buildSummaryAgentGroups(agents)
const visibleGroups = groups.slice(0, 3)
const hiddenGroupAgentCount = groups
.slice(visibleGroups.length)
.reduce((count, group) => count + group.agents.length, 0)
const agentIdentitySummary = summarizeAgentIdentities(agents)
const stopPointerPropagation = useCallback((e: React.SyntheticEvent) => {
e.stopPropagation()
}, [])
@@ -243,11 +164,13 @@ export function CompactAgentSummaryButton({
type="button"
draggable={false}
className={cn(
'group/agent-summary flex h-6 w-full min-w-0 items-center gap-1.5 rounded-sm border border-sidebar-border/70',
'bg-sidebar-accent/35 px-1.5 text-left text-[11px] leading-none text-muted-foreground',
'group/agent-summary flex h-6 w-full min-w-0 items-center gap-1 rounded-sm border border-sidebar-border/70',
'bg-sidebar-accent/35 px-1 text-left text-[11px] leading-none text-muted-foreground',
'hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring'
)}
aria-label={`${expanded ? 'Collapse' : 'Expand'} ${summary}`}
aria-label={
expanded ? `Collapse ${subjectLabel}` : `Expand ${summary}. ${agentIdentitySummary}`
}
aria-expanded={expanded}
onClick={handleToggle}
onKeyDown={stopActivationKeyPropagation}
@@ -255,22 +178,47 @@ export function CompactAgentSummaryButton({
onPointerDown={stopPointerPropagation}
onDragStart={stopPointerPropagation}
>
<span className="flex shrink-0 items-center gap-0.5" aria-hidden>
{iconAgents.map((agent) => (
<span
key={agent.paneKey}
className="inline-flex size-4 items-center justify-center rounded-full border border-sidebar bg-sidebar"
title={formatAgentTypeLabel(agent.agentType)}
>
<AgentIcon agent={agentTypeToIconAgent(agent.agentType)} size={12} />
</span>
))}
</span>
<span className="min-w-0 flex-1 truncate">{summary}</span>
{hiddenIconAgentCount > 0 && (
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/70">
+{hiddenIconAgentCount}
{expanded ? (
<span className="min-w-0 flex-1 truncate px-1 font-medium text-muted-foreground">
{subjectLabel}
</span>
) : (
<>
<span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden" aria-hidden>
{visibleGroups.map((group) => {
const iconAgents = selectSummaryGroupIconAgents(group.agents, 3)
const hiddenIconCount = Math.max(0, group.agents.length - iconAgents.length)
return (
<span
key={group.state}
className="inline-flex min-w-0 shrink-0 items-center gap-0.5 rounded-sm bg-sidebar/70 px-1 py-0.5"
>
<AgentStateDot state={group.state} size="sm" />
<span className="inline-flex shrink-0 items-center gap-0.5">
{iconAgents.map((agent) => (
<span
key={agent.paneKey}
className="inline-flex size-3.5 items-center justify-center rounded-full border border-sidebar-border/70 bg-sidebar"
>
<AgentIcon agent={agentTypeToIconAgent(agent.agentType)} size={10} />
</span>
))}
</span>
{hiddenIconCount > 0 && (
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/70">
+{hiddenIconCount}
</span>
)}
</span>
)
})}
</span>
{hiddenGroupAgentCount > 0 && (
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/70">
+{hiddenGroupAgentCount}
</span>
)}
</>
)}
<ChevronRight
className={cn('size-3 shrink-0 transition-transform duration-150', expanded && 'rotate-90')}
@@ -311,6 +259,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
const primary = getCompactAgentPrimary(agent)
const assistantMessage = agent.entry.lastAssistantMessage?.trim() ?? ''
const hasAssistantImage = MARKDOWN_IMAGE_PATTERN.test(assistantMessage)
const isLineageChild = agent.lineage?.depth === 1
const secondary = hasAssistantImage
? formatAgentTypeLabel(agent.agentType)
: getCompactAgentSecondary(agent)
@@ -337,7 +286,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
{hasChildDisclosure ? (
<button
type="button"
className="flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring"
className="flex size-4 shrink-0 items-center justify-center rounded-sm border border-sidebar-border/80 bg-sidebar text-foreground/80 shadow-xs hover:bg-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring"
aria-label={`${childAgentsExpanded ? 'Hide' : 'Show'} ${childAgentCount} child ${
childAgentCount === 1 ? 'agent' : 'agents'
}`}
@@ -385,6 +334,8 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
className={cn(
'group/compact-agent-row min-w-0 cursor-pointer rounded-sm px-1 text-[11px] leading-none',
'text-muted-foreground worktree-agent-row-hover',
hasChildDisclosure && 'worktree-agent-lineage-parent-row',
isLineageChild && 'worktree-agent-lineage-child-row',
hasAssistantImage ? 'flex flex-col py-0.5' : 'flex h-6 items-center gap-1',
isFocusedPane && 'bg-sidebar-accent'
)}
@@ -986,6 +986,10 @@ describe('project groups', () => {
expect(rows.filter((row) => row.type === 'header').map((row) => row.projectGroupDepth)).toEqual(
[0, 1, 2]
)
expect(rows.find((row) => row.type === 'item')).toMatchObject({
type: 'item',
groupDepth: 2
})
expect(rows[0]).toMatchObject({ count: 1 })
})
@@ -58,6 +58,7 @@ export type WorktreeRow = {
worktree: Worktree
repo: Repo | undefined
depth: number
groupDepth: number
lineageTrail: boolean[]
isLastLineageChild: boolean
lineageChildCount: number
@@ -246,7 +247,7 @@ function emitPinnedGroup(
const lastPinnedIndexByRepoId = new Map<string, number>()
pinned.forEach((worktree, index) => lastPinnedIndexByRepoId.set(worktree.repoId, index))
for (const [index, worktree] of pinned.entries()) {
result.push(buildWorktreeRow(worktree, repoMap, 0, [], false, 0, false))
result.push(buildWorktreeRow(worktree, repoMap, 0, 0, [], false, 0, false))
const candidate = importedWorktreesByRepo.get(worktree.repoId)
if (
candidate &&
@@ -277,6 +278,7 @@ function buildWorktreeRow(
worktree: Worktree,
repoMap: Map<string, Repo>,
depth: number,
groupDepth: number,
lineageTrail: boolean[],
isLastLineageChild: boolean,
lineageChildCount: number,
@@ -287,6 +289,7 @@ function buildWorktreeRow(
worktree,
repo: repoMap.get(worktree.repoId),
depth,
groupDepth,
lineageTrail,
isLastLineageChild,
lineageChildCount,
@@ -304,12 +307,13 @@ function appendWorktreeRows(
options: {
nestLineage: boolean
collapsedGroups: Set<string>
groupDepth: number
}
): void {
const { nestLineage, collapsedGroups } = options
const { nestLineage, collapsedGroups, groupDepth } = options
if (!nestLineage) {
for (const worktree of worktrees) {
result.push(buildWorktreeRow(worktree, repoMap, 0, [], false, 0, false))
result.push(buildWorktreeRow(worktree, repoMap, 0, groupDepth, [], false, 0, false))
}
return
}
@@ -347,6 +351,7 @@ function appendWorktreeRows(
worktree,
repoMap,
depth,
groupDepth,
lineageTrail,
isLastChild,
children.length,
@@ -445,7 +450,8 @@ export function buildRows(
if (!collapsedGroups.has(ALL_GROUP_KEY)) {
appendWorktreeRows(result, unpinned, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups
collapsedGroups,
groupDepth: 0
})
}
}
@@ -606,7 +612,8 @@ export function buildRows(
const items = groupBy === 'repo' ? orderMainWorktreeFirst(group.items) : group.items
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups
collapsedGroups,
groupDepth: projectGroupDepth
})
}
}
@@ -51,6 +51,7 @@ const makeWorktreeRow = (id: string): Extract<Row, { type: 'item' }> => ({
worktree: makeWorktree(id),
repo,
depth: 0,
groupDepth: 0,
lineageTrail: [],
isLastLineageChild: false,
lineageChildCount: 0
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import {
getProjectGroupHeaderPaddingLeft,
getWorktreeCardContentIndent
} from './worktree-list-indentation'
describe('worktree list indentation', () => {
it('keeps ungrouped workspaces flush with the list', () => {
expect(getWorktreeCardContentIndent({ isGrouped: false, groupDepth: 4, lineageDepth: 0 })).toBe(
0
)
})
it('indents workspace content one step deeper than its containing project header', () => {
expect(getWorktreeCardContentIndent({ isGrouped: true, groupDepth: 0, lineageDepth: 0 })).toBe(
18
)
expect(getWorktreeCardContentIndent({ isGrouped: true, groupDepth: 1, lineageDepth: 0 })).toBe(
36
)
})
it('adds lineage depth after project/group depth', () => {
expect(getWorktreeCardContentIndent({ isGrouped: true, groupDepth: 1, lineageDepth: 2 })).toBe(
72
)
})
it('caps header indentation separately from workspace content indentation', () => {
expect(getProjectGroupHeaderPaddingLeft(100)).toBe(70)
})
})
@@ -0,0 +1,24 @@
export const SIDEBAR_TREE_INDENT = 18
export const PROJECT_GROUP_HEADER_BASE_PADDING = 10
export const PROJECT_GROUP_HEADER_INDENT = 10
export const MAX_PROJECT_GROUP_HEADER_DEPTH = 6
function clampDepth(depth: number): number {
return Math.max(0, Math.floor(Number.isFinite(depth) ? depth : 0))
}
export function getProjectGroupHeaderPaddingLeft(depth: number): number {
return (
PROJECT_GROUP_HEADER_BASE_PADDING +
Math.min(clampDepth(depth), MAX_PROJECT_GROUP_HEADER_DEPTH) * PROJECT_GROUP_HEADER_INDENT
)
}
export function getWorktreeCardContentIndent(args: {
isGrouped: boolean
groupDepth: number
lineageDepth: number
}): number {
const groupSteps = args.isGrouped ? clampDepth(args.groupDepth) + 1 : 0
return (groupSteps + clampDepth(args.lineageDepth)) * SIDEBAR_TREE_INDENT
}
@@ -51,6 +51,7 @@ const makeWorktreeRow = (id: string): Extract<Row, { type: 'item' }> => ({
worktree: makeWorktree(id),
repo,
depth: 0,
groupDepth: 0,
lineageTrail: [],
isLastLineageChild: false,
lineageChildCount: 0
+48 -6
View File
@@ -2204,7 +2204,7 @@ export function useIpcEvents(): void {
if (!payload) {
return 'dropped'
}
const {
let {
exists,
title,
identityTitle,
@@ -2212,6 +2212,19 @@ export function useIpcEvents(): void {
repoConnectionResolved,
owningWorktreeId
} = resolvePaneKey(store, data.paneKey)
if (!exists && data.worktreeId) {
// Why: orchestration worker hooks can carry main-side worktree
// attribution before this renderer has a terminal tab for the pane.
// Accept those only when the worktree is known, then keep the normal
// repo connection check below for SSH/local ownership.
const fallbackOwnership = resolveWorktreeConnection(store, data.worktreeId)
if (fallbackOwnership.worktreeExists) {
owningWorktreeId = data.worktreeId
repoConnectionId = fallbackOwnership.repoConnectionId
repoConnectionResolved = fallbackOwnership.repoConnectionResolved
exists = true
}
}
if (!exists) {
// Why: empty paneKeys are dropped in main before IPC fanout. Reaching
// this branch means a non-empty paneKey escaped without a matching
@@ -2293,12 +2306,21 @@ export function useIpcEvents(): void {
return 'dropped'
}
const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title)
store.setAgentStatus(data.paneKey, statusPayload, terminalTitle, {
updatedAt: data.receivedAt,
stateStartedAt: data.stateStartedAt
})
applyResolvedAgentTerminalTitleToTab(store, data.paneKey, title, terminalTitle)
const statusWorktreeId = data.worktreeId ?? owningWorktreeId
store.setAgentStatus(
data.paneKey,
statusPayload,
terminalTitle,
{
updatedAt: data.receivedAt,
stateStartedAt: data.stateStartedAt
},
{
tabId: data.tabId,
worktreeId: statusWorktreeId
}
)
applyResolvedAgentTerminalTitleToTab(store, data.paneKey, title, terminalTitle)
if (options?.replay !== true && statusWorktreeId) {
// Why: local Codex/Claude hooks arrive through this main-process IPC
// path, not the PTY OSC fallback, so task-complete notifications must
@@ -2683,6 +2705,26 @@ function resolvePaneKey(
}
}
function resolveWorktreeConnection(
store: ReturnType<typeof useAppStore.getState>,
worktreeId: string
): {
worktreeExists: boolean
repoConnectionId: string | null
repoConnectionResolved: boolean
} {
const worktree = getWorktreeMapFromState(store).get(worktreeId)
if (!worktree) {
return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false }
}
const repo = getRepoMapFromState(store).get(worktree.repoId)
return {
worktreeExists: true,
repoConnectionId: repo?.connectionId ?? null,
repoConnectionResolved: repo !== undefined
}
}
function resolveHookPayloadAgentType(
payload: ParsedAgentStatusPayload,
terminalTitle: string | undefined
@@ -107,6 +107,34 @@ describe('agent status freshness expiry', () => {
})
})
describe('agent status routing attribution', () => {
afterEach(() => {
vi.useRealTimers()
})
it('stores worktree and tab attribution from accepted hook events', () => {
vi.useFakeTimers()
const store = createTestStore()
store
.getState()
.setAgentStatus(
'tab-child:11111111-1111-4111-8111-111111111111',
{ state: 'working', prompt: 'child agent', agentType: 'codex' },
undefined,
undefined,
{ tabId: 'tab-child', worktreeId: 'wt-1' }
)
expect(
store.getState().agentStatusByPaneKey['tab-child:11111111-1111-4111-8111-111111111111']
).toMatchObject({
tabId: 'tab-child',
worktreeId: 'wt-1'
})
})
})
describe('agent status tool + assistant fields', () => {
// Why: setAgentStatus schedules a real 30-minute freshness setTimeout via
// queueMicrotask. Without fake timers those handles leak into the test
@@ -61,7 +61,8 @@ export type AgentStatusSlice = {
paneKey: string,
payload: ParsedAgentStatusPayload & { orchestration?: AgentStatusOrchestrationContext },
terminalTitle?: string,
timing?: { updatedAt?: number; stateStartedAt?: number }
timing?: { updatedAt?: number; stateStartedAt?: number },
routing?: { tabId?: string; worktreeId?: string }
) => void
setMigrationUnsupportedPty: (entry: MigrationUnsupportedPtyEntry) => void
@@ -200,7 +201,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
retainedAgentsByPaneKey: {},
retentionSuppressedPaneKeys: {},
setAgentStatus: (paneKey, payload, terminalTitle, timing) => {
setAgentStatus: (paneKey, payload, terminalTitle, timing, routing) => {
const updatedAt = timing?.updatedAt ?? Date.now()
let completionRefreshWorktreeId: string | null = null
let suppressedInheritedTerminalStatus = false
@@ -288,6 +289,12 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
stateStartedAt,
agentType: identity.agentType,
paneKey,
worktreeId:
routing?.worktreeId ??
existing?.worktreeId ??
findAgentPaneWorktreeId(s, paneKey) ??
undefined,
tabId: routing?.tabId ?? existing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined,
terminalTitle: effectiveTitle,
stateHistory: history,
toolName: payload.toolName,
+7
View File
@@ -82,6 +82,13 @@ export type AgentStatusEntry = {
agentType?: AgentType
/** Composite key: `${tabId}:${leafId}` where leafId is a stable UUID layout leaf. */
paneKey: string
/** Worktree attribution stamped by main when a hook can be resolved there.
* Why: orchestration workers can report status before their terminal tab is
* present in a renderer; retaining this lets worktree-level UI still show
* the live child agent instead of dropping it as unattributed. */
worktreeId?: string
/** Tab attribution from the hook IPC payload, when available. */
tabId?: string
terminalTitle?: string
/** Rolling log of previous states. Each entry records a state the agent was in
* before transitioning to the current one. Capped at AGENT_STATE_HISTORY_MAX. */