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