From d9cf07d3f4a3b2bc114a745ef39ff6a0585fcfef Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:25:36 -0700 Subject: [PATCH] Fix pinned workspace reveal expanding other hosts (#25836) --- .../src/components/sidebar/WorktreeList.tsx | 2 + .../sidebar/host-pinned-sections.test.ts | 84 +++++++++++ .../sidebar/host-pinned-sections.ts | 31 ++++ .../worktree-list/grouping/row-types.ts | 1 + .../listing/use-collapsed-groups.ts | 17 ++- .../worktree-list/listing/use-section-rows.ts | 68 +++++---- .../navigation/pending-reveal-inputs.ts | 10 +- .../navigation/reveal-ancestors.ts | 6 +- .../worktree-list/rows/SectionHeader.tsx | 9 +- tests/e2e/worktree-reveal-host-pinned.spec.ts | 133 ++++++++++++++++++ 10 files changed, 329 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/components/sidebar/host-pinned-sections.test.ts create mode 100644 src/renderer/src/components/sidebar/host-pinned-sections.ts create mode 100644 tests/e2e/worktree-reveal-host-pinned.spec.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 8fe26780866..c5f2dd7a483 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -123,6 +123,8 @@ const WorktreeList = React.memo(function WorktreeList({ agentSendTargetWorktreeId }) const effectiveCollapsedGroups = useEffectiveCollapsedGroups({ + hostScopedPinnedGroups: + filterState.workspaceHostScope !== 'all' || filterState.visibleWorkspaceHostIds !== null, collapsedGroups, agentSendTargetWorktreeId, groupBy, diff --git a/src/renderer/src/components/sidebar/host-pinned-sections.test.ts b/src/renderer/src/components/sidebar/host-pinned-sections.test.ts new file mode 100644 index 00000000000..5c213e4d5a7 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-pinned-sections.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { makeRepo, makeWorktree } from './worktree-list-lineage-card-test-fixtures' +import { buildRows } from './worktree-list/grouping/build-rows' +import { addHostSectionRows } from './host-section-rows' +import { scopePinnedSectionCollapse } from './host-pinned-sections' + +for (const hostId of ['ssh:builder', 'runtime:builder'] as const) { + describe(`pinned sections on ${hostId}`, () => { + const localRepo = makeRepo() + const remoteRepo = { ...localRepo, id: 'remote-repo', executionHostId: hostId } + const repoMap = new Map([ + [localRepo.id, localRepo], + [remoteRepo.id, remoteRepo] + ]) + const local = { + ...makeWorktree({ + id: 'local', + instanceId: 'local', + displayName: 'Local pin', + branch: 'local', + sortOrder: 0 + }), + hostId: 'local' as const, + isPinned: true + } + const remote = { ...local, id: 'remote', repoId: remoteRepo.id, hostId } + const rows = buildRows('none', [local, remote], repoMap, null, new Set()) + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { id: 'local', kind: 'local', label: 'Local', detail: '', health: 'local' }, + { + id: hostId, + kind: hostId.startsWith('ssh:') ? 'ssh' : 'runtime', + label: 'Remote', + detail: '', + health: 'available' + } + ], + workspaceHostScope: 'all', + visibleWorkspaceHostIds: ['local', hostId], + defaultHostId: 'local' + }) + + it('keeps remote pins visible while local pins are collapsed', () => { + const result = scopePinnedSectionCollapse({ + rows: sectioned, + collapsedGroups: new Set(['pinned']), + defaultHostId: 'local' + }) + expect(result.filter((row) => row.type === 'item').map((row) => row.worktree.id)).toEqual([ + 'remote' + ]) + expect(result.filter((row) => row.type === 'header').map((row) => row.collapseKey)).toEqual([ + 'pinned', + `pinned:host:${hostId}` + ]) + }) + + it('keeps local pins visible while remote pins are collapsed', () => { + const result = scopePinnedSectionCollapse({ + rows: sectioned, + collapsedGroups: new Set([`pinned:host:${hostId}`]), + defaultHostId: 'local' + }) + expect(result.filter((row) => row.type === 'item').map((row) => row.worktree.id)).toEqual([ + 'local' + ]) + }) + + it('retains remote collapse ownership when it is the only visible host', () => { + const remoteRows = buildRows('none', [remote], repoMap, null, new Set()) + const result = scopePinnedSectionCollapse({ + rows: remoteRows, + collapsedGroups: new Set([`pinned:host:${hostId}`]), + defaultHostId: 'local' + }) + expect(result.filter((row) => row.type === 'item')).toEqual([]) + expect(result.filter((row) => row.type === 'header').map((row) => row.collapseKey)).toEqual([ + `pinned:host:${hostId}` + ]) + }) + }) +} diff --git a/src/renderer/src/components/sidebar/host-pinned-sections.ts b/src/renderer/src/components/sidebar/host-pinned-sections.ts new file mode 100644 index 00000000000..5253aa5df0c --- /dev/null +++ b/src/renderer/src/components/sidebar/host-pinned-sections.ts @@ -0,0 +1,31 @@ +import { getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import type { HostSectionRow } from './host-section-rows' +import { PINNED_GROUP_KEY } from './worktree-list/grouping/group-keys' + +export function getHostPinnedGroupKey(hostId: ExecutionHostId): string { + return hostId === 'local' ? PINNED_GROUP_KEY : `${PINNED_GROUP_KEY}:host:${hostId}` +} + +export function scopePinnedSectionCollapse(args: { + rows: readonly HostSectionRow[] + collapsedGroups: ReadonlySet + defaultHostId: ExecutionHostId +}): HostSectionRow[] { + return args.rows.flatMap((row): HostSectionRow[] => { + if (row.type === 'header' && row.key === PINNED_GROUP_KEY) { + const hostId = + row.hostId ?? + (row.hostWorktreeCounts?.size === 1 + ? row.hostWorktreeCounts.keys().next().value + : undefined) + return [{ ...row, collapseKey: hostId ? getHostPinnedGroupKey(hostId) : row.key }] + } + if (row.type === 'item' && row.sectionKey === PINNED_GROUP_KEY) { + const hostId = getWorktreeExecutionHostId(row.worktree, row.repo, args.defaultHostId) + if (args.collapsedGroups.has(getHostPinnedGroupKey(hostId))) { + return [] + } + } + return [row] + }) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts index 92deca9e964..e6c8d100776 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts @@ -16,6 +16,7 @@ export function getPinnedWorktreeDisplayPolicy( export type GroupHeaderRow = { type: 'header' + collapseKey?: string key: string label: string count: number diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts index 2c838f00247..6918a1162ce 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts @@ -11,11 +11,14 @@ import { getGroupKeysForWorktree } from '../grouping/worktree-group-keys' import { getFolderWorkspaceRevealGroupKeys } from '../navigation/folder-reveal' import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types' import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { getWorktreeExecutionHostId } from '../../../../../../shared/execution-host' +import { getHostPinnedGroupKey } from '../../host-pinned-sections' import { isPinnedSectionWorktree } from '../../pinned-section-worktrees' import { getWorktreeLineageAncestors } from '../../worktree-lineage-projection' // While the agent send picker targets a workspace, force open every section that hides it. export function useEffectiveCollapsedGroups(args: { + hostScopedPinnedGroups?: boolean collapsedGroups: Set agentSendTargetWorktreeId: string | null groupBy: WorktreeGroupBy @@ -34,6 +37,7 @@ export function useEffectiveCollapsedGroups(args: { }): Set { const { collapsedGroups, + hostScopedPinnedGroups = false, agentSendTargetWorktreeId, groupBy, pinnedDisplayPolicy, @@ -77,7 +81,17 @@ export function useEffectiveCollapsedGroups(args: { pinnedDisplayPolicy === 'single-location' && isPinnedSectionWorktree(targetWorktree, visibleWorktrees, worktreeLineageById, worktreeMap) ) { - next.delete(PINNED_GROUP_KEY) + next.delete( + hostScopedPinnedGroups + ? getHostPinnedGroupKey( + getWorktreeExecutionHostId( + targetWorktree, + repoMap.get(targetWorktree.repoId), + defaultHostId + ) + ) + : PINNED_GROUP_KEY + ) } else { for (const groupKey of getGroupKeysForWorktree( groupBy, @@ -104,6 +118,7 @@ export function useEffectiveCollapsedGroups(args: { }, [ agentSendTargetWorktreeId, collapsedGroups, + hostScopedPinnedGroups, groupBy, pinnedDisplayPolicy, visibleWorktrees, diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts index ddd91a23653..34c1232e5ce 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts @@ -16,6 +16,8 @@ import type { ProjectGroupingModel } from '../grouping/project-grouping' import type { PinnedWorktreeDisplayPolicy, Row, WorktreeGroupBy } from '../grouping/row-types' import { getLogicalRepoOrderRankById } from '../../project-header-drop' import { getEmptyProjectPlaceholderRepoIds } from '../../empty-project-placeholder-repos' +import { PINNED_GROUP_KEY } from '../grouping/group-keys' +import { scopePinnedSectionCollapse } from '../../host-pinned-sections' import { addHostSectionRows } from '../../host-section-rows' import { orderHostSectionOptions } from '../../host-section-order' import { buildSidebarHostOptions } from '../../sidebar-host-options' @@ -141,6 +143,18 @@ export function useSidebarSectionRows(args: SectionRowsArgs) { [hostOptions] ) + const hostScopedPinnedGroups = + args.workspaceHostScope !== 'all' || args.visibleWorkspaceHostIds !== null + // Host sections apply pinned collapse after splitting the rows by owner. + const rowCollapsedGroups = useMemo(() => { + if (!hostScopedPinnedGroups || !effectiveCollapsedGroups.has(PINNED_GROUP_KEY)) { + return effectiveCollapsedGroups + } + const next = new Set(effectiveCollapsedGroups) + next.delete(PINNED_GROUP_KEY) + return next + }, [effectiveCollapsedGroups, hostScopedPinnedGroups]) + const rows: Row[] = useMemo( () => buildRows( @@ -148,7 +162,7 @@ export function useSidebarSectionRows(args: SectionRowsArgs) { worktrees, repoMap, args.prCache, - effectiveCollapsedGroups, + rowCollapsedGroups, repoOrder, args.workspaceStatuses, args.projectOrderBy, @@ -172,7 +186,7 @@ export function useSidebarSectionRows(args: SectionRowsArgs) { worktrees, repoMap, args.prCache, - effectiveCollapsedGroups, + rowCollapsedGroups, defaultHostId, repoOrder, args.workspaceStatuses, @@ -215,29 +229,35 @@ export function useSidebarSectionRows(args: SectionRowsArgs) { }, [orderedHostOptions, setWorkspaceHostOrder, workspaceHostOrder] ) - const sectionRows = useMemo( - () => - addHostSectionRows({ - rows, - hostOptions: orderedHostOptions, - workspaceHostScope: args.workspaceHostScope, - visibleWorkspaceHostIds: args.visibleWorkspaceHostIds, - defaultHostId, - collapsedHostKeys: effectiveCollapsedGroups, - forceCollapseHosts: hostDragActive, - // Why: projects/workspaces are the primary sidebar object; host sections are only an explicit host-filter view. - preferProjectGrouping: true - }), - [ - args.visibleWorkspaceHostIds, - args.workspaceHostScope, + const sectionRows = useMemo(() => { + const sectioned = addHostSectionRows({ + rows, + hostOptions: orderedHostOptions, + workspaceHostScope: args.workspaceHostScope, + visibleWorkspaceHostIds: args.visibleWorkspaceHostIds, defaultHostId, - effectiveCollapsedGroups, - hostDragActive, - orderedHostOptions, - rows - ] - ) + collapsedHostKeys: effectiveCollapsedGroups, + forceCollapseHosts: hostDragActive, + // Why: projects/workspaces are the primary sidebar object; host sections are only an explicit host-filter view. + preferProjectGrouping: true + }) + return hostScopedPinnedGroups + ? scopePinnedSectionCollapse({ + rows: sectioned, + collapsedGroups: effectiveCollapsedGroups, + defaultHostId + }) + : sectioned + }, [ + args.visibleWorkspaceHostIds, + args.workspaceHostScope, + defaultHostId, + effectiveCollapsedGroups, + hostDragActive, + hostScopedPinnedGroups, + orderedHostOptions, + rows + ]) const renderedSidebarRowKeys = useMemo( () => collectRenderedSidebarRowKeys(sectionRows), [sectionRows] diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts index 09f4c4d333f..91f1d43871e 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts @@ -10,6 +10,7 @@ import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage- import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { getWorktreeExecutionHostId } from '../../../../../../shared/execution-host' import type { RenderRow } from '../listing/render-row' +import { getHostPinnedGroupKey } from '../../host-pinned-sections' import { getWorktreeLineageGroupKey } from '../grouping/group-keys' import type { ProjectGroupingModel } from '../grouping/project-grouping' import type { PinnedWorktreeDisplayPolicy, WorktreeGroupBy } from '../grouping/row-types' @@ -87,7 +88,8 @@ export function expandGroupsForWorktreeReveal( return } const targetRepo = args.repoMap.get(targetWorktree.repoId) - const hostGroupKey = `host:${getWorktreeExecutionHostId(targetWorktree, targetRepo, args.defaultHostId)}` + const targetHostId = getWorktreeExecutionHostId(targetWorktree, targetRepo, args.defaultHostId) + const hostGroupKey = `host:${targetHostId}` if (args.collapsedGroups.has(hostGroupKey)) { args.toggleGroup(hostGroupKey) } @@ -118,6 +120,12 @@ export function expandGroupsForWorktreeReveal( ) ? getPinnedWorktreeRevealCollapsedGroupKeys({ worktree: targetWorktree, + groupKey: args.renderRows.some( + (row) => + row.type === 'host-header' || (row.type === 'header' && row.collapseKey !== undefined) + ) + ? getHostPinnedGroupKey(targetHostId) + : undefined, collapsedGroups: args.collapsedGroups, inPinnedSection: true }) diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts index 968118aac48..2be2aa92bd2 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts @@ -93,10 +93,12 @@ export function getSidebarRowRevealAncestorKeys(args: { export function getPinnedWorktreeRevealCollapsedGroupKeys({ worktree, collapsedGroups, + groupKey = PINNED_GROUP_KEY, inPinnedSection = worktree.isPinned }: { worktree: Worktree collapsedGroups: ReadonlySet + groupKey?: string inPinnedSection?: boolean }): string[] { if (!inPinnedSection) { @@ -104,8 +106,8 @@ export function getPinnedWorktreeRevealCollapsedGroupKeys({ } const keys: string[] = [] // Why: the reveal effect already opens this host; re-returning it would toggle it back closed. - if (collapsedGroups.has(PINNED_GROUP_KEY)) { - keys.push(PINNED_GROUP_KEY) + if (collapsedGroups.has(groupKey)) { + keys.push(groupKey) } return keys } diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx index db0c7ec1371..e189fa18f76 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx @@ -171,7 +171,8 @@ export function renderWorktreeSectionHeaderRow(args: { projectGroupId: folderBackedProjectGroup.id }) : null - const isHeaderCollapsed = ctx.collapsedGroups.has(row.key) + const collapseKey = row.collapseKey ?? row.key + const isHeaderCollapsed = ctx.collapsedGroups.has(collapseKey) // Why: repo/project/status/pinned share compact section chrome; flat "All" stays a simple label. const showHeaderCollapseAffordance = row.count > 0 && @@ -284,7 +285,7 @@ export function renderWorktreeSectionHeaderRow(args: { if (shouldIgnoreRepoHeaderToggle(event)) { return } - ctx.toggleGroupWithScrollAnchor(row.key) + ctx.toggleGroupWithScrollAnchor(collapseKey) }} onKeyDown={(e) => { if (shouldIgnoreRepoHeaderToggle(e)) { @@ -292,7 +293,7 @@ export function renderWorktreeSectionHeaderRow(args: { } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - ctx.toggleGroupWithScrollAnchor(row.key) + ctx.toggleGroupWithScrollAnchor(collapseKey) } }} > @@ -350,7 +351,7 @@ export function renderWorktreeSectionHeaderRow(args: { onClick={(event) => { event.preventDefault() event.stopPropagation() - ctx.toggleGroupWithScrollAnchor(row.key) + ctx.toggleGroupWithScrollAnchor(collapseKey) }} > { + await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const ids = await orcaPage.evaluate( + async ({ hostId, collapsedHost }) => { + const store = window.__store! + const state = store.getState() + const sourceRepo = state.repos[0] + const sourceWorktree = state.worktreesByRepo[sourceRepo.id][0] + await state.updateWorktreeMeta(sourceWorktree.id, { + isPinned: true, + displayName: 'Local pinned workspace' + }) + const local = { + ...sourceWorktree, + hostId: 'local' as const, + isPinned: true, + displayName: 'Local pinned workspace' + } + const remoteRepo = { + ...sourceRepo, + id: 'reveal-remote-repo', + executionHostId: hostId, + connectionId: hostId.startsWith('ssh:') ? 'reveal-builder' : null, + displayName: 'Remote project' + } + const remote = { + ...sourceWorktree, + id: 'reveal-remote-workspace', + repoId: remoteRepo.id, + hostId, + isPinned: true, + isMainWorktree: false, + displayName: 'Remote pinned workspace' + } + state.setActiveView('terminal') + state.setSidebarOpen(true) + await window.api.ui.set({ groupBy: 'none' }) + state.setShowSleepingWorkspaces(true) + state.setHideDefaultBranchWorkspace(false) + state.setFilterRepoIds([]) + const collapsedGroups = [ + 'pinned', + `pinned:host:${hostId}`, + ...(collapsedHost ? [`host:${hostId}`] : []) + ] + await window.api.ui.set({ collapsedGroups }) + store.setState({ + groupBy: 'none', + repos: [sourceRepo, remoteRepo], + worktreesByRepo: { [sourceRepo.id]: [local], [remoteRepo.id]: [remote] }, + sshTargetLabels: new Map([['reveal-builder', 'Remote builder']]), + sshConnectionStates: new Map([ + [ + 'reveal-builder', + { + targetId: 'reveal-builder', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + ] + ]), + visibleWorkspaceHostIds: ['local', hostId], + workspaceHostScope: 'all', + activeRepoId: remoteRepo.id, + activeWorktreeId: remote.id, + activeWorkspaceKey: `worktree:${remote.id}`, + activeWorkspaceExecutionHostId: hostId, + pendingRevealWorktree: null, + collapsedGroups: new Set(collapsedGroups) + }) + return { local: local.id, remote: remote.id } + }, + { hostId, collapsedHost } + ) + const pins = orcaPage.getByRole('button', { name: /^Pinned/ }) + const localRow = orcaPage.locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.local)}]` + ) + const remoteRow = orcaPage.locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.remote)}]` + ) + await expect(pins.first()).toHaveAttribute('aria-expanded', 'false') + await expect(localRow).toHaveCount(0) + await expect(remoteRow).toHaveCount(0) + await orcaPage.screenshot({ + path: testInfo.outputPath('before-reveal.png'), + clip: { x: 0, y: 170, width: 280, height: 500 } + }) + await orcaPage.getByRole('button', { name: 'Reveal active workspace' }).click() + await expect(remoteRow).toBeVisible() + await expect(remoteRow).toHaveAttribute('data-scroll-reveal-highlight', 'true') + await orcaPage.screenshot({ + path: testInfo.outputPath('after-reveal.png'), + clip: { x: 0, y: 170, width: 280, height: 500 } + }) + await expect(pins).toHaveCount(2) + await expect(pins.first()).toHaveAttribute('aria-expanded', 'false') + await expect(pins.last()).toHaveAttribute('aria-expanded', 'true') + await expect(localRow).toHaveCount(0) + + await pins.last().click() + await expect(remoteRow).toHaveCount(0) + await orcaPage.evaluate((localId) => { + const store = window.__store! + const local = Object.values(store.getState().worktreesByRepo) + .flat() + .find((worktree) => worktree.id === localId)! + store.setState({ + activeRepoId: local.repoId, + activeWorktreeId: localId, + activeWorkspaceKey: `worktree:${localId}`, + activeWorkspaceExecutionHostId: 'local', + pendingRevealWorktree: null + }) + }, ids.local) + await orcaPage.getByRole('button', { name: 'Reveal active workspace' }).click() + await expect(localRow).toBeVisible() + await expect(pins.first()).toHaveAttribute('aria-expanded', 'true') + await expect(pins.last()).toHaveAttribute('aria-expanded', 'false') + await expect(remoteRow).toHaveCount(0) + }) + } +}