diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index 5981b049a1b..c670db92280 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -525,6 +525,27 @@ describe('WorktreeCardAgents', () => { expect(markup).toContain('>+2<') }) + it('rotates the compact summary chevron when collapsed', async () => { + const { CompactAgentSummaryButton } = await import('./worktree-card-compact-agents') + const agents = [ + mockAgent({ paneKey: 'tab-1:1', agentType: 'codex', startedAt: 1000, prompt: 'One' }) + ] as DashboardAgentRowData[] + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('aria-expanded="false"') + expect(markup).toContain( + 'lucide-chevron-down size-3 shrink-0 transition-transform duration-150 -rotate-90' + ) + }) + it('uses a neutral compact summary label while expanded', async () => { const { CompactAgentSummaryButton } = await import('./worktree-card-compact-agents') const agents = [ 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 c6c23a9bbbd..16b4bb826e8 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 @@ -599,7 +599,10 @@ function setLineageFixtureState( } } -function setProjectGroupWithoutWorktreeRowsState(filterRepoIds: string[] = []): void { +function setProjectGroupWithoutWorktreeRowsState( + filterRepoIds: string[] = [], + collapsedGroups = new Set() +): void { const group: ProjectGroup = { id: 'group-1', name: 'Imported Services', @@ -626,7 +629,7 @@ function setProjectGroupWithoutWorktreeRowsState(filterRepoIds: string[] = []): agentStatusByPaneKey: {}, browserTabsByWorktree: {}, clearPendingRevealWorktreeId: vi.fn(), - collapsedGroups: new Set(), + collapsedGroups, deleteStateByWorktreeId: {}, filterRepoIds, ...makeFolderWorkspacePathStatusState(), @@ -773,6 +776,46 @@ describe('WorktreeList lineage child card renderer', () => { expect(markup).not.toContain('No workspaces found') }) + it('renders a collapse chevron on project group headers with children', async () => { + setProjectGroupWithoutWorktreeRowsState() + const markup = await renderWorktreeListMarkup() + + expect(markup).toContain('data-repo-header-collapse-affordance=""') + expect(markup).toContain('aria-expanded="true"') + }) + + it('renders collapsed project group header affordance state', async () => { + setProjectGroupWithoutWorktreeRowsState([], new Set(['project-group:group-1'])) + const markup = await renderWorktreeListMarkup() + + expect(markup).toContain('data-repo-header-collapse-affordance=""') + expect(markup).toContain('aria-expanded="false"') + expect(markup).toContain('-rotate-90') + }) + + it('does not render the project collapse affordance on flat section headers', async () => { + setLineageFixtureState('none') + const markup = await renderWorktreeListMarkup() + + expect(markup).not.toContain('data-repo-header-collapse-affordance=""') + }) + + it('renders a collapse chevron on grouped repo headers with worktrees', async () => { + setLineageFixtureState('repo') + const markup = await renderWorktreeListMarkup() + + expect(markup).toContain('data-repo-header-collapse-affordance=""') + expect(markup).toContain('data-repo-header-id="repo-1"') + expect(markup).toContain('aria-expanded="true"') + }) + + it('does not render the collapse affordance on empty ungrouped projects', async () => { + setEmptyUngroupedProjectState() + const markup = await renderWorktreeListMarkup() + + expect(markup).not.toContain('data-repo-header-collapse-affordance=""') + }) + it('shows Clear Filters when filters exclude pre-worktree project groups', async () => { setProjectGroupWithoutWorktreeRowsState(['another-repo']) const markup = await renderWorktreeListMarkup() diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 3d59451d1d2..e9e26fec4a5 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -348,6 +348,14 @@ function handleRepoHeaderActionPointerDown(event: React.PointerEvent +): void { + // Why: repo-header drag arms from the row press surface; keep collapse + // clicks on the hover chevron from promoting into a drag session. + event.stopPropagation() +} + function stopRepoHeaderMenuEvent(event: React.SyntheticEvent): void { event.stopPropagation() } @@ -3615,6 +3623,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp (isConfirmedStaleFolderPathStatus(projectGroupPathStatus) || projectGroupPathStatus.reason === 'ambiguous-connection') const projectGroupDepth = row.projectGroupDepth ?? 0 + const isHeaderCollapsed = collapsedGroups.has(row.key) + // Why: repo/project headers already reveal actions on hover; tuck + // the collapse chevron into that cluster instead of a new surface. + const showHeaderCollapseAffordance = + row.count > 0 && (isRepoHeader || isProjectGroupHeader) // Why: non-project section headers like "All" are labels for the // flat list, so they should not reserve project hierarchy indent. const headerPaddingLeft = @@ -3652,6 +3665,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
+ {showHeaderCollapseAffordance ? ( +
{ + event.preventDefault() + event.stopPropagation() + toggleGroupWithScrollAnchor(row.key) + }} + > + +
+ ) : null} + {isProjectGroupHeader && !row.repo && row.projectGroup?.id ? ( diff --git a/src/renderer/src/components/sidebar/project-header-drag-contract.ts b/src/renderer/src/components/sidebar/project-header-drag-contract.ts index 18662d5f25e..c8e3bc1465a 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-contract.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-contract.ts @@ -48,7 +48,7 @@ export const PROJECT_HEADER_DRAG_THRESHOLD_PX = 4 const REPO_HEADER_DRAG_HANDLE_SELECTOR = '[data-repo-header-drag-handle]' const REPO_HEADER_ACTION_SELECTOR = - '[data-repo-header-action], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' + '[data-repo-header-action], [data-repo-header-collapse-affordance], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' export function isProjectHeaderDragHandleTarget( target: EventTarget | null, diff --git a/src/renderer/src/components/sidebar/project-header-drag.test.ts b/src/renderer/src/components/sidebar/project-header-drag.test.ts index b53a4e868bc..2ef27043fd6 100644 --- a/src/renderer/src/components/sidebar/project-header-drag.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drag.test.ts @@ -34,4 +34,14 @@ describe('repo header action targets', () => { expect(isRepoHeaderActionTarget(header.querySelector('#label'), header)).toBe(false) expect(isRepoHeaderActionTarget(header, header)).toBe(false) }) + + it('ignores the hover collapse affordance', () => { + const header = createHeader(` +
+ +
+ `) + + expect(isRepoHeaderActionTarget(header.querySelector('#chevron'), header)).toBe(true) + }) }) diff --git a/src/renderer/src/components/sidebar/project-header-drag.ts b/src/renderer/src/components/sidebar/project-header-drag.ts index ac84dc620be..0dbbb655907 100644 --- a/src/renderer/src/components/sidebar/project-header-drag.ts +++ b/src/renderer/src/components/sidebar/project-header-drag.ts @@ -53,7 +53,7 @@ export type RepoHeaderDragController = { // real drag. Below this we treat the press as a normal click (toggle group). const DRAG_THRESHOLD_PX = 4 const REPO_HEADER_ACTION_SELECTOR = - '[data-repo-header-action], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' + '[data-repo-header-action], [data-repo-header-collapse-affordance], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' export function isRepoHeaderActionTarget( target: EventTarget | null, 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 3f43b3b62f8..8eb6dc8a594 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useRef } from 'react' -import { ChevronRight } from 'lucide-react' +import { ChevronDown } from 'lucide-react' import { AgentStateDot } from '@/components/AgentStateDot' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { AgentIcon } from '@/lib/agent-catalog' @@ -179,8 +179,11 @@ export function CompactAgentSummaryButton({ )} )} -