From d9fba56bdaa6ac5174aaa7d3c5a4e38470620b35 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sun, 31 May 2026 22:15:50 -0700
Subject: [PATCH] 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.
---
src/renderer/src/assets/main.css | 77 ++++++-
.../DashboardAgentChildDisclosure.tsx | 2 +-
.../dashboard/DashboardAgentRow.test.tsx | 21 ++
.../dashboard/DashboardAgentRow.tsx | 2 +
.../src/components/sidebar/WorktreeCard.tsx | 18 +-
.../sidebar/WorktreeCardAgents.test.tsx | 61 +++++-
.../components/sidebar/WorktreeCardAgents.tsx | 37 ++--
.../WorktreeList.lineage-child-card.test.ts | 35 ++-
.../src/components/sidebar/WorktreeList.tsx | 202 +++++++++---------
.../focused-agent-row-highlight.test.ts | 33 +--
.../sidebar/focused-agent-row-highlight.ts | 19 +-
.../sidebar/useWorktreeAgentRows.test.ts | 25 +++
.../worktree-agent-row-selectors.test.ts | 20 ++
.../sidebar/worktree-agent-row-selectors.ts | 2 +-
.../components/sidebar/worktree-agent-rows.ts | 43 ++++
.../sidebar/worktree-card-agent-summary.ts | 135 ++++++++++++
.../sidebar/worktree-card-compact-agents.tsx | 191 ++++++-----------
.../sidebar/worktree-list-groups.test.ts | 4 +
.../sidebar/worktree-list-groups.ts | 17 +-
.../worktree-list-imported-rows.test.ts | 1 +
.../sidebar/worktree-list-indentation.test.ts | 32 +++
.../sidebar/worktree-list-indentation.ts | 24 +++
.../worktree-list-sticky-headers.test.ts | 1 +
src/renderer/src/hooks/useIpcEvents.ts | 54 ++++-
.../src/store/slices/agent-status.test.ts | 28 +++
src/renderer/src/store/slices/agent-status.ts | 11 +-
src/shared/agent-status-types.ts | 7 +
27 files changed, 817 insertions(+), 285 deletions(-)
create mode 100644 src/renderer/src/components/sidebar/worktree-card-agent-summary.ts
create mode 100644 src/renderer/src/components/sidebar/worktree-list-indentation.test.ts
create mode 100644 src/renderer/src/components/sidebar/worktree-list-indentation.ts
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 7caa44fa34a..25af2b43070 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -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;
}
diff --git a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx
index 5cf8d05273e..e5c86b17631 100644
--- a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx
+++ b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx
@@ -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'
}`}
diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx
index 7c5b5c87c5c..95058d85d51 100644
--- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx
+++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx
@@ -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(
+
+
+
+ )
+
+ expect(classTokens(markup)).toContain('worktree-agent-lineage-parent-row')
+ expect(markup).toContain('aria-label="Show 2 child agents"')
+ })
})
diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
index 928c1afc722..125c4bf299d 100644
--- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
+++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
@@ -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'
)}
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx
index 56ad15f3ccd..2d40c6c3bbb 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx
@@ -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 = (
+
{/* Header row: Title */}
@@ -977,7 +983,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
- {lineageChildren &&
{lineageChildren}
}
+ {lineageChildren && (
+
+ {lineageChildren}
+
+ )}
)
diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx
index bcdbec02986..0c1da6de5a4 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx
@@ -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(
)
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(
+
+ )
+
+ 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(
+
+ Agent row
+
+ )
+
+ 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')
})
diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
index 87883bf0684..f586e1c2a3d 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
@@ -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
>(
+ const [collapsedLineageParents, setCollapsedLineageParents] = useState>(
() => 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 ? (
+
+ {childAgents.map((childAgent) =>
renderAgentBranch(childAgent, descendantAncestorPaneKeys)
- )
- : null}
+ )}
+
+ ) : null}
)
}
@@ -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 ? (
- {childAgents.map((childAgent) =>
- renderCompactAgentBranch(childAgent, descendantAncestorPaneKeys)
- )}
+
+ {childAgents.map((childAgent) =>
+ renderCompactAgentBranch(childAgent, descendantAncestorPaneKeys)
+ )}
+
) : null}
@@ -451,7 +464,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
{!shouldUseSummaryRow ? (
rootAgents.map((rootAgent) => renderCompactAgentBranch(rootAgent))
) : shouldUseSummaryRow ? (
-
+
{rootAgents.map((rootAgent) => renderCompactAgentBranch(rootAgent))}
) : null}
diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
index b7b4f79be07..b557326829b 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
+++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
@@ -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', () => {
''
)
})
+
+ it('adds project group depth to workspace card content indentation', async () => {
+ setLineageFixtureState('repo', { projectGrouped: true })
+ const markup = await renderWorktreeListMarkup()
+
+ const parentRow = markup.match(/]*id="worktree-list-option-parent"[^>]*>/)?.[0] ?? ''
+
+ expect(parentRow).not.toContain('padding-left')
+ expect(markup).toContain(
+ '
'
+ )
+ })
})
diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx
index 1a9499fd0ed..896c74e7167 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeList.tsx
@@ -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 (
-
+
event.stopPropagation()}
>
-
-
-
-
-
- {child.worktree.displayName}
-
-
- {child.repo && groupBy !== 'repo' ? (
-
-
-
- {child.repo.displayName}
-
-
- ) : null}
-
- {branchDisplayName(child.worktree.branch)}
-
-
- {child.worktree.linkedIssue || child.worktree.comment ? (
-
- {child.worktree.linkedIssue ? (
-
- #{child.worktree.linkedIssue}
+ 0
+ ? { paddingLeft: `calc(0.5rem + ${childContentIndent}px)` }
+ : undefined
+ }
+ >
+
+
+
+
+
+ {child.worktree.displayName}
+
+
+ {child.repo && groupBy !== 'repo' ? (
+
+
+
+ {child.repo.displayName}
+
) : null}
- {child.worktree.linkedIssue && child.worktree.comment ? ' ' : null}
- {child.worktree.comment}
+
+ {branchDisplayName(child.worktree.branch)}
+
- ) : null}
- {showInlineAgentCards ? (
- // Why: nested lineage children use this lightweight
- // renderer instead of WorktreeCard, so their inline
- // agent rows must be mounted here explicitly.
-
- ) : null}
- {child.lineageChildCount > 0 && lineageToggleGroupKey ? (
-
-
-
-
-
-
- {child.lineageCollapsed
- ? 'Show child workspaces'
- : 'Hide child workspaces'}
-
-
-
- ) : null}
+ {child.worktree.linkedIssue || child.worktree.comment ? (
+
+ {child.worktree.linkedIssue ? (
+
+ #{child.worktree.linkedIssue}
+
+ ) : null}
+ {child.worktree.linkedIssue && child.worktree.comment ? ' ' : null}
+ {child.worktree.comment}
+
+ ) : null}
+ {showInlineAgentCards ? (
+ // Why: nested lineage children use this lightweight
+ // renderer instead of WorktreeCard, so their inline
+ // agent rows must be mounted here explicitly.
+
+ ) : null}
+ {child.lineageChildCount > 0 && lineageToggleGroupKey ? (
+
+
+
+
+
+
+ {child.lineageCollapsed
+ ? 'Show child workspaces'
+ : 'Hide child workspaces'}
+
+
+
+ ) : null}
+
diff --git a/src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts b/src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts
index 1f5c87eb6d9..c3cd300892e 100644
--- a/src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts
+++ b/src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts
@@ -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)
})
})
diff --git a/src/renderer/src/components/sidebar/focused-agent-row-highlight.ts b/src/renderer/src/components/sidebar/focused-agent-row-highlight.ts
index 8ddc911ca3c..5f6b2dabac9 100644
--- a/src/renderer/src/components/sidebar/focused-agent-row-highlight.ts
+++ b/src/renderer/src/components/sidebar/focused-agent-row-highlight.ts
@@ -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)
-}
diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts
index 52d3555bb9f..a98fd144828 100644
--- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts
+++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts
@@ -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', () => {
diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts
index 40be718f267..b94ebc02f2e 100644
--- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts
@@ -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', () => {
diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts
index 8159e187e95..b79b356e3fc 100644
--- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts
+++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts
@@ -90,7 +90,7 @@ function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map
()
+ 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()
+ 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()
+ 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
+}
diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx
index 2eb5009cf5c..78ba15986ff 100644
--- a/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx
+++ b/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx
@@ -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()
- 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()
- 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({
>
{shouldRenderChildren && (
-
+
{children}
)}
@@ -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}
>
-
- {iconAgents.map((agent) => (
-
-
-
- ))}
-
-
{summary}
- {hiddenIconAgentCount > 0 && (
-
- +{hiddenIconAgentCount}
+ {expanded ? (
+
+ {subjectLabel}
+ ) : (
+ <>
+
+ {visibleGroups.map((group) => {
+ const iconAgents = selectSummaryGroupIconAgents(group.agents, 3)
+ const hiddenIconCount = Math.max(0, group.agents.length - iconAgents.length)
+ return (
+
+
+
+ {iconAgents.map((agent) => (
+
+
+
+ ))}
+
+ {hiddenIconCount > 0 && (
+
+ +{hiddenIconCount}
+
+ )}
+
+ )
+ })}
+
+ {hiddenGroupAgentCount > 0 && (
+
+ +{hiddenGroupAgentCount}
+
+ )}
+ >
)}
{
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 })
})
diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts
index 0eeca8a6747..7d6678a8e0c 100644
--- a/src/renderer/src/components/sidebar/worktree-list-groups.ts
+++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts
@@ -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()
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,
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
+ 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
})
}
}
diff --git a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts
index 96e5dae930e..9960ea363cc 100644
--- a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts
@@ -51,6 +51,7 @@ const makeWorktreeRow = (id: string): Extract => ({
worktree: makeWorktree(id),
repo,
depth: 0,
+ groupDepth: 0,
lineageTrail: [],
isLastLineageChild: false,
lineageChildCount: 0
diff --git a/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts b/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts
new file mode 100644
index 00000000000..781518ece8e
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts
@@ -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)
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-list-indentation.ts b/src/renderer/src/components/sidebar/worktree-list-indentation.ts
new file mode 100644
index 00000000000..04b4cae656e
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-list-indentation.ts
@@ -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
+}
diff --git a/src/renderer/src/components/sidebar/worktree-list-sticky-headers.test.ts b/src/renderer/src/components/sidebar/worktree-list-sticky-headers.test.ts
index 0ccd595f23c..d4a8827a038 100644
--- a/src/renderer/src/components/sidebar/worktree-list-sticky-headers.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-list-sticky-headers.test.ts
@@ -51,6 +51,7 @@ const makeWorktreeRow = (id: string): Extract => ({
worktree: makeWorktree(id),
repo,
depth: 0,
+ groupDepth: 0,
lineageTrail: [],
isLastLineageChild: false,
lineageChildCount: 0
diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts
index 5f7d97adfe3..c8053cab6c5 100644
--- a/src/renderer/src/hooks/useIpcEvents.ts
+++ b/src/renderer/src/hooks/useIpcEvents.ts
@@ -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,
+ 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
diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts
index e50526fb23b..0d281db8df2 100644
--- a/src/renderer/src/store/slices/agent-status.test.ts
+++ b/src/renderer/src/store/slices/agent-status.test.ts
@@ -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
diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts
index f61df8367d9..b229d2a3d6e 100644
--- a/src/renderer/src/store/slices/agent-status.ts
+++ b/src/renderer/src/store/slices/agent-status.ts
@@ -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 {
+ 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