From a9e81c3fa55239254afae6dcc2a890579ee06c5a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:12:47 -0700 Subject: [PATCH] Show full details for child worktree cards (#5056) --- .../src/components/sidebar/WorktreeCard.tsx | 14 +- .../WorktreeList.lineage-child-card.test.ts | 178 ++++++-- ...ktreeList.lineage-child-real-card.test.tsx | 392 ++++++++++++++++++ .../src/components/sidebar/WorktreeList.tsx | 369 +++-------------- 4 files changed, 589 insertions(+), 364 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 00b14970910..7656534555b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -506,6 +506,11 @@ const WorktreeCard = React.memo(function WorktreeCard({ event.stopPropagation() return } + if (isDeleting) { + event.preventDefault() + event.stopPropagation() + return + } // Why: route sidebar clicks through the shared activation path so the // back/forward stack stays complete for the primary worktree navigation // surface instead of only recording palette-driven switches. @@ -516,7 +521,14 @@ const WorktreeCard = React.memo(function WorktreeCard({ } onActivate?.() }, - [worktree.id, isSshDisconnected, onActivate, onImmediateActivate, onSelectionGesture] + [ + worktree.id, + isDeleting, + isSshDisconnected, + onActivate, + onImmediateActivate, + onSelectionGesture + ] ) const handleRenameTitle = useCallback( 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 2bdf232b760..7e59ad969d6 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 @@ -59,25 +59,75 @@ vi.mock('./project-header-drag', () => ({ vi.mock('./WorktreeCard', () => ({ default: ({ worktree, + repo, + isActive, contentIndent, flushSurface, + lineageChildCount, + lineageCollapsed, lineageChildren }: { worktree: Worktree + repo?: Repo + isActive?: boolean contentIndent?: number flushSurface?: boolean + lineageChildCount?: number + lineageCollapsed?: boolean lineageChildren?: React.ReactNode - }) => - React.createElement( + }) => { + const deleteStateByWorktreeId = + (mockStore.state.deleteStateByWorktreeId as Record< + string, + { isDeleting?: boolean } | undefined + >) ?? {} + const cardProps = (mockStore.state.worktreeCardProperties as string[] | undefined) ?? [] + const sshState = + repo?.connectionId && mockStore.state.sshConnectionStates instanceof Map + ? mockStore.state.sshConnectionStates.get(repo.connectionId) + : null + const isDeleting = deleteStateByWorktreeId[worktree.id]?.isDeleting === true + const showSshDialog = isActive && repo?.connectionId && sshState?.status !== 'connected' + + return React.createElement( 'section', { 'data-worktree-card-id': worktree.id, + 'data-worktree-card-active': isActive ? 'true' : undefined, 'data-content-indent': contentIndent, - 'data-flush-surface': flushSurface ? 'true' : undefined + 'data-flush-surface': flushSurface ? 'true' : undefined, + 'data-lineage-child-count': lineageChildCount, + 'data-lineage-collapsed': + lineageCollapsed === undefined ? undefined : String(lineageCollapsed), + 'data-linked-pr': worktree.linkedPR ?? undefined, + 'data-linked-gitlab-mr': worktree.linkedGitLabMR ?? undefined, + 'aria-busy': isDeleting ? 'true' : undefined }, React.createElement('h2', null, worktree.displayName), + isDeleting ? React.createElement('span', null, 'Deleting') : null, + cardProps.includes('unread') && worktree.isUnread + ? React.createElement('button', { 'aria-label': 'Mark as read' }, 'Unread') + : null, + lineageChildCount + ? React.createElement( + 'button', + { + 'data-lineage-toggle-for': worktree.id, + 'aria-expanded': lineageCollapsed ? 'false' : 'true' + }, + `${lineageChildCount} ${lineageChildCount === 1 ? 'child' : 'children'}` + ) + : null, + showSshDialog + ? React.createElement('aside', { + 'data-worktree-card-ssh-dialog': 'open', + 'data-ssh-status': sshState?.status ?? 'disconnected', + 'data-ssh-target-id': repo?.connectionId + }) + : null, lineageChildren ) + } })) vi.mock('./WorktreeCardAgents', () => ({ @@ -208,6 +258,7 @@ function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage { function setLineageFixtureState( groupBy: 'none' | 'repo' = 'none', options: { + childWorktreeOverrides?: Partial deletingWorktreeIds?: string[] projectGrouped?: boolean unreadWorktreeIds?: string[] @@ -243,6 +294,7 @@ function setLineageFixtureState( branch: 'child-branch', sortOrder: 20 }) + Object.assign(child, options.childWorktreeOverrides) const grandchild = makeWorktree({ id: 'grandchild', instanceId: 'grandchild-instance', @@ -458,6 +510,18 @@ async function renderWorktreeListMarkup(): Promise { ) } +function getCardOpeningTag(markup: string, worktreeId: string): string { + return ( + markup.match(new RegExp(`]*data-worktree-card-id="${worktreeId}"[^>]*>`))?.[0] ?? '' + ) +} + +function getOptionOpeningTag(markup: string, worktreeId: string): string { + return ( + markup.match(new RegExp(`]*id="worktree-list-option-${worktreeId}"[^>]*>`))?.[0] ?? '' + ) +} + describe('WorktreeList lineage child card renderer', () => { beforeAll(async () => { WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent @@ -497,63 +561,76 @@ describe('WorktreeList lineage child card renderer', () => { expect(markup).not.toContain('empty-project') }) - it('renders nested inline agent rows before the nested child-count toggle', async () => { + it('renders recursive lineage descendants through WorktreeCard once', async () => { setLineageFixtureState() const markup = await renderWorktreeListMarkup() - const childStart = markup.indexOf('lineage child with agent') - const agentRowIndex = markup.indexOf('Review fixture prompt', childStart) - const childToggleIndex = markup.indexOf('1 child', childStart) + expect(markup.match(/data-worktree-card-id="parent"/g)).toHaveLength(1) + expect(markup.match(/data-worktree-card-id="child"/g)).toHaveLength(1) + expect(markup.match(/data-worktree-card-id="grandchild"/g)).toHaveLength(1) - expect(childStart).toBeGreaterThan(-1) - expect(agentRowIndex).toBeGreaterThan(childStart) - expect(childToggleIndex).toBeGreaterThan(childStart) - expect(agentRowIndex).toBeLessThan(childToggleIndex) + const parentIndex = markup.indexOf('data-worktree-card-id="parent"') + const childIndex = markup.indexOf('data-worktree-card-id="child"') + const grandchildIndex = markup.indexOf('data-worktree-card-id="grandchild"') + + expect(parentIndex).toBeGreaterThan(-1) + expect(childIndex).toBeGreaterThan(parentIndex) + expect(grandchildIndex).toBeGreaterThan(childIndex) + expect(getCardOpeningTag(markup, 'child')).toContain('data-lineage-child-count="1"') }) - it('renders nested child titles through the inline rename surface', async () => { - setLineageFixtureState() + it('passes child review details through the shared WorktreeCard path', async () => { + setLineageFixtureState('none', { + childWorktreeOverrides: { linkedPR: 456, linkedGitLabMR: 42 } + }) const markup = await renderWorktreeListMarkup() + const childCard = getCardOpeningTag(markup, 'child') - expect(markup).toContain('data-worktree-title-inline-rename=""') - expect(markup).toContain('lineage child with agent') + expect(childCard).toContain('data-linked-pr="456"') + expect(childCard).toContain('data-linked-gitlab-mr="42"') }) - it('nests the first-level child workspace card surface under its parent', async () => { + it('uses shared nested-row indentation for child and grandchild cards', async () => { setLineageFixtureState() const markup = await renderWorktreeListMarkup() - expect(markup).toContain('
{ setLineageFixtureState('none', { deletingWorktreeIds: ['child'] }) const markup = await renderWorktreeListMarkup() - - const childCard = - markup.match(/
{ setLineageFixtureState('none', { unreadWorktreeIds: ['child'] }) mockStore.state.worktreeCardProperties = ['status', 'unread', 'inline-agents'] const markup = await renderWorktreeListMarkup() + const childIndex = markup.indexOf('data-worktree-card-id="child"') + const childMarkup = markup.slice( + childIndex, + markup.indexOf('data-worktree-card-id="grandchild"') + ) - const childCard = - markup.match(/
{ + it('lets WorktreeCard own the reconnect dialog for an active disconnected lineage child', async () => { setLineageFixtureState() const repo = (mockStore.state.repos as Repo[])[0]! repo.connectionId = 'ssh-target-1' @@ -563,17 +640,18 @@ describe('WorktreeList lineage child card renderer', () => { const markup = await renderWorktreeListMarkup() - expect(markup).toContain('data-lineage-ssh-dialog="open"') + expect(getCardOpeningTag(markup, 'child')).toContain('data-worktree-card-active="true"') + expect(markup).toContain('data-worktree-card-ssh-dialog="open"') + expect(markup).not.toContain('data-lineage-ssh-dialog="open"') expect(markup).toContain('data-ssh-status="disconnected"') expect(markup).toContain('data-ssh-target-id="ssh-target-1"') - expect(markup).toContain('data-ssh-target-label="Remote target"') }) it('does not add group indentation when grouping is disabled', async () => { setLineageFixtureState('none') const markup = await renderWorktreeListMarkup() - const parentRow = markup.match(/]*id="worktree-list-option-parent"[^>]*>/)?.[0] ?? '' + const parentRow = getOptionOpeningTag(markup, 'parent') expect(parentRow).toContain('id="worktree-list-option-parent"') expect(parentRow).not.toContain('padding-left') @@ -583,23 +661,39 @@ describe('WorktreeList lineage child card renderer', () => { setLineageFixtureState('repo') const markup = await renderWorktreeListMarkup() - const parentRow = markup.match(/]*id="worktree-list-option-parent"[^>]*>/)?.[0] ?? '' + const parentRow = getOptionOpeningTag(markup, 'parent') expect(parentRow).not.toContain('padding-left') - expect(markup).toContain( - '
' - ) + expect(getCardOpeningTag(markup, 'parent')).toContain('data-content-indent="20"') + expect(getCardOpeningTag(markup, 'parent')).toContain('data-flush-surface="true"') + }) + + it('keeps nested card inner padding aligned with grouped parent cards', async () => { + setLineageFixtureState('repo') + const markup = await renderWorktreeListMarkup() + + expect(getOptionOpeningTag(markup, 'child')).toContain('padding-left:14px') + expect(getCardOpeningTag(markup, 'child')).toContain('data-content-indent="20"') + expect(getCardOpeningTag(markup, 'child')).toContain('data-flush-surface="true"') + }) + + it('keeps nested card inner padding aligned inside project groups', async () => { + setLineageFixtureState('repo', { projectGrouped: true }) + const markup = await renderWorktreeListMarkup() + + expect(getOptionOpeningTag(markup, 'child')).toContain('padding-left:14px') + expect(getCardOpeningTag(markup, 'child')).toContain('data-content-indent="38"') + expect(getCardOpeningTag(markup, 'child')).toContain('data-flush-surface="true"') }) it('adds project group depth to workspace card content indentation', async () => { setLineageFixtureState('repo', { projectGrouped: true }) const markup = await renderWorktreeListMarkup() - const parentRow = markup.match(/]*id="worktree-list-option-parent"[^>]*>/)?.[0] ?? '' + const parentRow = getOptionOpeningTag(markup, 'parent') expect(parentRow).not.toContain('padding-left') - expect(markup).toContain( - '
' - ) + expect(getCardOpeningTag(markup, 'parent')).toContain('data-content-indent="38"') + expect(getCardOpeningTag(markup, 'parent')).toContain('data-flush-surface="true"') }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx new file mode 100644 index 00000000000..9130c45a0f6 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -0,0 +1,392 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { + Repo, + Worktree, + WorktreeCardProperty, + WorktreeLineage +} from '../../../../shared/types' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const mockStore = vi.hoisted(() => ({ + state: {} as Record, + activateWorktreeFromSidebar: vi.fn(), + openModal: vi.fn(), + updateWorktreeMeta: vi.fn(), + fetchHostedReviewForBranch: vi.fn(), + fetchIssue: vi.fn(), + fetchLinearIssue: vi.fn(), + openTaskPage: vi.fn() +})) + +type WorktreeListComponent = React.ComponentType<{ + scrollOffsetRef: React.RefObject + scrollAnchorRef: React.RefObject +}> + +let WorktreeList: WorktreeListComponent + +vi.mock('@/store', () => { + const useAppStore = ((selector: (state: Record) => unknown) => + selector(mockStore.state)) as (( + selector: (state: Record) => unknown + ) => unknown) & { + getState: () => Record + } + useAppStore.getState = () => mockStore.state + return { useAppStore } +}) + +vi.mock('@tanstack/react-virtual', () => ({ + defaultRangeExtractor: ({ startIndex, endIndex }: { startIndex: number; endIndex: number }) => + Array.from({ length: endIndex - startIndex + 1 }, (_, index) => startIndex + index), + measureElement: () => 32, + useVirtualizer: ({ count }: { count: number }) => ({ + elementsCache: new Map(), + getTotalSize: () => count * 96, + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: `row-${index}`, + start: index * 96 + })), + measureElement: vi.fn(), + scrollToIndex: vi.fn() + }) +})) + +vi.mock('@/hooks/useVirtualizedScrollAnchor', () => ({ + VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT: 'orca:test-record-scroll-anchor', + useVirtualizedScrollAnchor: vi.fn() +})) + +vi.mock('./project-header-drag', () => ({ + useRepoHeaderDrag: () => ({ + state: { draggingRepoId: null, dropIndicatorY: null }, + onHandlePointerDown: vi.fn() + }), + isRepoHeaderActionTarget: () => false +})) + +vi.mock('@/components/ui/hover-card', () => ({ + HoverCard: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => ( + + ), + DropdownMenuSeparator: () =>
, + DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/lib/sidebar-worktree-activation', () => ({ + activateWorktreeFromSidebar: mockStore.activateWorktreeFromSidebar +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }), + callRuntimeRpc: vi.fn() +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownStartedAt: () => null +})) + +vi.mock('./WorktreeCardAgents', () => ({ + default: ({ worktreeId }: { worktreeId: string }) => ( +
Agent row
+ ), + SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment' +})) + +vi.mock('./SshDisconnectedDialog', () => ({ + SshDisconnectedDialog: () => null +})) + +vi.mock('./WorktreeContextMenu', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, + CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', + WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', + WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' +})) + +function makeRepo(): Repo { + return { + id: 'repo-1', + path: '/tmp/lineage-real-card', + displayName: 'lineage-real-card', + badgeColor: '#999999', + addedAt: 1 + } +} + +function makeWorktree(args: { + id: string + displayName: string + branch: string + sortOrder: number + instanceId: string + overrides?: Partial +}): Worktree { + return { + id: args.id, + instanceId: args.instanceId, + repoId: 'repo-1', + path: `/tmp/lineage-real-card/${args.id}`, + displayName: args.displayName, + branch: args.branch, + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: args.sortOrder, + lastActivityAt: args.sortOrder, + ...args.overrides + } +} + +function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage { + return { + worktreeId: worktree.id, + worktreeInstanceId: worktree.instanceId!, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId!, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'explicit' }, + createdAt: 1 + } +} + +function makeHostedReview(overrides: Partial = {}): HostedReviewInfo { + return { + provider: 'gitlab', + number: 42, + title: 'Child GitLab MR', + state: 'open', + url: 'https://gitlab.com/acme/orca/-/merge_requests/42', + status: 'success', + updatedAt: '2026-06-09T00:00:00.000Z', + mergeable: 'MERGEABLE', + ...overrides + } +} + +function setLineageState(options: { deletingChild?: boolean } = {}): void { + const repo = makeRepo() + const parent = makeWorktree({ + id: 'parent', + instanceId: 'parent-instance', + displayName: 'lineage parent', + branch: 'parent-branch', + sortOrder: 20 + }) + const child = makeWorktree({ + id: 'child', + instanceId: 'child-instance', + displayName: 'lineage child', + branch: 'child-branch', + sortOrder: 10, + overrides: { + linkedGitLabMR: 42, + comment: 'Child handoff note' + } + }) + mockStore.state = { + activeModal: '', + activeView: 'terminal', + activeWorktreeId: null, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserTabsByWorktree: {}, + clearPendingRevealWorktreeId: vi.fn(), + collapsedGroups: new Set(), + deleteStateByWorktreeId: options.deletingChild + ? { [child.id]: { isDeleting: true, error: null, canForceDelete: false } } + : {}, + detectedWorktreesByRepo: {}, + fetchHostedReviewForBranch: mockStore.fetchHostedReviewForBranch, + fetchIssue: mockStore.fetchIssue, + fetchLinearIssue: mockStore.fetchLinearIssue, + filterRepoIds: [], + gitConflictOperationByWorktree: {}, + groupBy: 'none', + hideDefaultBranchWorkspace: false, + hostedReviewCache: { + 'local::repo-1::child-branch': { + data: makeHostedReview(), + fetchedAt: Date.now(), + linkedReviewHintKey: 'gitlab:42' + } + }, + issueCache: {}, + linearIssueCache: {}, + linearStatus: null, + migrationUnsupportedByPtyId: {}, + openModal: mockStore.openModal, + openSettingsPage: vi.fn(), + openSettingsTarget: null, + openTaskPage: mockStore.openTaskPage, + pendingRevealWorktree: null, + prCache: {}, + projectGroups: [], + ptyIdsByTabId: {}, + recordFeatureInteraction: vi.fn(), + remoteBranchConflictByWorktreeId: {}, + reorderRepos: vi.fn(), + reportVisibleGitHubPRRefreshCandidates: vi.fn(), + repos: [repo], + retainedAgentsByPaneKey: {}, + revealWorktreeInSidebar: vi.fn(), + runtimePaneTitlesByTabId: {}, + setFilterRepoIds: vi.fn(), + setHideDefaultBranchWorkspace: vi.fn(), + setRenamingWorktreeId: vi.fn(), + setShowSleepingWorkspaces: vi.fn(), + setSortBy: vi.fn(), + setWorktreesPinnedAndReveal: vi.fn(), + settings: null, + showSleepingWorkspaces: true, + sortBy: 'manual', + sortEpoch: 0, + sshConnectedGeneration: 0, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + toggleCollapsedGroup: vi.fn(), + updateRepo: vi.fn(), + updateWorktreeMeta: mockStore.updateWorktreeMeta, + updateWorktreesMeta: vi.fn(), + workspacePortScan: null, + workspaceStatuses: [], + worktreeCardProperties: [ + 'status', + 'pr', + 'comment', + 'inline-agents' + ] satisfies WorktreeCardProperty[], + worktreeLineageById: { + [child.id]: makeLineage(child, parent) + }, + worktreesByRepo: { + [repo.id]: [parent, child] + } + } +} + +const mountedRoots: Root[] = [] + +async function renderWorktreeList(): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + await act(async () => { + root.render( + + ) + }) + return container +} + +describe('WorktreeList real child WorktreeCard integration', () => { + beforeAll(async () => { + WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent + }, 20_000) + + beforeEach(() => { + vi.clearAllMocks() + setLineageState() + }) + + afterEach(async () => { + await act(async () => { + for (const root of mountedRoots.splice(0)) { + root.unmount() + } + }) + document.body.innerHTML = '' + }) + + it('renders GitLab MR metadata from a child through the real WorktreeCard path', async () => { + const container = await renderWorktreeList() + const childOption = container.querySelector('#worktree-list-option-child') + + expect(childOption?.textContent).toContain('MR #42') + expect(childOption?.textContent).toContain('Child GitLab MR') + expect(childOption?.textContent).toContain('Child handoff note') + }) + + it('double-clicking a nested child opens edit metadata for the child only', async () => { + const container = await renderWorktreeList() + const childCard = container.querySelector( + '#worktree-list-option-child [data-worktree-card-surface="true"]' + ) + + expect(childCard).not.toBeNull() + await act(async () => { + childCard!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + }) + + expect(mockStore.openModal).toHaveBeenCalledTimes(1) + expect(mockStore.openModal).toHaveBeenCalledWith( + 'edit-meta', + expect.objectContaining({ + worktreeId: 'child', + currentDisplayName: 'lineage child', + currentComment: 'Child handoff note' + }) + ) + expect(mockStore.openModal).not.toHaveBeenCalledWith( + 'edit-meta', + expect.objectContaining({ worktreeId: 'parent' }) + ) + }) + + it('does not activate a nested child while it is deleting', async () => { + setLineageState({ deletingChild: true }) + const container = await renderWorktreeList() + const childCard = container.querySelector( + '#worktree-list-option-child [data-worktree-card-surface="true"]' + ) + + expect(childCard?.textContent).toContain('Deleting') + await act(async () => { + childCard!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mockStore.activateWorktreeFromSidebar).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index facdd6afee4..d3328771e6e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -14,13 +14,11 @@ import { Eye, FolderInput, FolderPlus, - Loader2, Plus, Server, Shapes, SlidersHorizontal, - Trash2, - Workflow + Trash2 } from 'lucide-react' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' @@ -33,12 +31,7 @@ import { } from '@/store/selectors' import WorktreeCard from './WorktreeCard' import { PendingWorktreeRow } from './PendingWorktreeRow' -import WorktreeCardAgents, { - SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT -} from './WorktreeCardAgents' -import { WorktreeTitleInlineRename } from './WorktreeTitleInlineRename' -import { SshDisconnectedDialog } from './SshDisconnectedDialog' -import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' +import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from './WorktreeCardAgents' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { @@ -120,14 +113,12 @@ import { type VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, type ScrollToCurrentWorkspaceRevealRequestDetail } from '@/lib/scroll-to-current-workspace-status' import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag' -import WorktreeContextMenu from './WorktreeContextMenu' import { buildManualOrderUpdatesForGroupDrop, buildManualOrderUpdatesForVisibleGroups, @@ -179,8 +170,7 @@ import { pruneWorktreeSelection, updateWorktreeSelection } from './worktree-multi-selection' -import { branchDisplayName } from './WorktreeCardHelpers' -import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { splitWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-host-split' import { getSettingsFocusedExecutionHostId, @@ -199,7 +189,6 @@ import { } from '../../../../shared/worktree-ownership' import { RepoIconGlyph } from '@/components/repo/repo-icon' import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator' -import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' import ImportedWorktreesVisibilityLine from './ImportedWorktreesVisibilityLine' import { keepImportedWorktreesHiddenCard, @@ -373,10 +362,10 @@ function getWorktreeVisibilityMenuLabel(repo: Repo): string { return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees' } -// Why: child workspace cards are already nested inside the parent card body; -// using the full tree step makes the second-level card drift too far right. -const LINEAGE_INDENT = 14 const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4 +// Why: nested child worktree cards sit inside the parent card body. Preserve +// the legacy lineage surface offset instead of using the full sidebar tree step. +const NESTED_LINEAGE_CARD_INDENT = 14 type VirtualizedWorktreeViewportProps = { rows: HostSectionRow[] @@ -407,7 +396,6 @@ type VirtualizedWorktreeViewportProps = { selectedWorktrees: readonly Worktree[] onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean onImmediateWorktreeActivate: (worktreeId: string) => void - onToggleWorktreeUnread: (worktree: Worktree) => void onContextMenuSelect: ( event: React.MouseEvent, worktree: Worktree @@ -450,7 +438,6 @@ type VirtualizedWorktreeViewportProps = { draggedIds: readonly string[] dropIndex: number }) => void - showInlineAgentCards: boolean // Why: broad grouping changes still remount the viewport, while add/delete // stays mounted for row-key anchoring and layout animation. These refs bridge // both paths so the virtualizer never falls back to scrollTop 0. @@ -839,7 +826,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp selectedWorktrees, onSelectionGesture, onImmediateWorktreeActivate, - onToggleWorktreeUnread, onContextMenuSelect, repoMap, worktreeMap, @@ -857,7 +843,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onDropWorktreesOnWorkspaceBoard, shouldShowWorkspaceBoardDropIndicator, onReorderWorktrees, - showInlineAgentCards, scrollOffsetRef, scrollAnchorRef }: VirtualizedWorktreeViewportProps) { @@ -866,7 +851,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const directScrollInputUntilRef = useRef(0) const [dragOverStatus, setDragOverStatus] = useState(null) const [pinDragOver, setPinDragOver] = useState(false) - const [lineageReconnectWorktreeId, setLineageReconnectWorktreeId] = useState(null) const [worktreeDragState, setWorktreeDragState] = useState( WORKTREE_ROW_DRAG_INITIAL_STATE ) @@ -875,9 +859,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const [highlightedRevealWorktreeId, setHighlightedRevealWorktreeId] = useState( null ) - const renamingWorktreeId = useAppStore((s) => s.renamingWorktreeId) const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) - const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const worktreeDragSessionRef = useRef(null) const worktreePointerDragRef = useRef(null) const worktreePointerAutoscrollFrameIdRef = useRef(null) @@ -946,7 +928,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration) const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration) const settings = useAppStore((s) => s.settings) - const deleteStateByWorktreeId = useAppStore((s) => s.deleteStateByWorktreeId) const reorderRepos = useAppStore((s) => s.reorderRepos) useEffect( @@ -1110,41 +1091,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp () => renderRows.findIndex((row) => renderRowContainsWorktree(row, activeWorktreeId)), [renderRows, activeWorktreeId] ) - const activeLineageChildRow = useMemo(() => { - if (activeWorktreeId === null) { - return null - } - for (const row of renderRows) { - if (row.type !== 'lineage-group') { - continue - } - const child = row.rows.slice(1).find((item) => item.worktree.id === activeWorktreeId) - if (child) { - return child - } - } - return null - }, [activeWorktreeId, renderRows]) - const activeLineageChildWorktreeId = activeLineageChildRow?.worktree.id ?? null - const activeLineageChildConnectionId = activeLineageChildRow?.repo?.connectionId ?? null - const activeLineageChildSshStatus = useAppStore((s) => - activeLineageChildConnectionId - ? (s.sshConnectionStates.get(activeLineageChildConnectionId)?.status ?? 'disconnected') - : null - ) - const activeLineageChildTargetLabel = useAppStore((s) => - activeLineageChildConnectionId ? s.sshTargetLabels.get(activeLineageChildConnectionId) : null - ) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - const activeLineageChildSshDisconnected = - activeLineageChildSshStatus !== null && activeLineageChildSshStatus !== 'connected' - const lineageReconnectPromptKey = - activeLineageChildWorktreeId && activeLineageChildSshDisconnected - ? activeLineageChildWorktreeId - : null - const [lastLineageReconnectPromptKey, setLastLineageReconnectPromptKey] = useState( - null - ) const renderRowsRef = useRef(renderRows) renderRowsRef.current = renderRows const getVirtualItemKey = useCallback( @@ -1635,16 +1582,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) }, [activeModal, keybindings, markDirectScrollInput, navigateWorktree]) - // Why: lightweight nested cards do not mount WorktreeCard, so the viewport - // owns the SSH reconnect prompt for an active lineage child. The prompt key - // keeps dismissals sticky until the active/disconnected child changes. - if (lineageReconnectPromptKey !== lastLineageReconnectPromptKey) { - setLastLineageReconnectPromptKey(lineageReconnectPromptKey) - if (lineageReconnectPromptKey) { - setLineageReconnectWorktreeId(lineageReconnectPromptKey) - } - } - const handleContainerKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { @@ -2869,24 +2806,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp className="worktree-sidebar-scrollbar h-full overflow-y-scroll overflow-x-hidden pl-1 scrollbar-sleek outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset pt-px" style={WORKTREE_SIDEBAR_SCROLL_STYLE} > - {activeLineageChildConnectionId && activeLineageChildSshStatus ? ( - { - if (!open) { - setLineageReconnectWorktreeId(null) - } - }} - targetId={activeLineageChildConnectionId} - targetLabel={ - activeLineageChildTargetLabel ?? activeLineageChildRow?.repo?.displayName ?? '' - } - status={activeLineageChildSshStatus} - /> - ) : null}
{ const lineageToggleGroupKey = itemRow.lineageGroupKey - // Why: child cards render inside the parent card body, so their - // first nested level starts flush with that inset. + // Why: child card rows own lineage depth, while WorktreeCard + // still owns the project/group inset inside each card surface. const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth + const nestedCardPaddingLeft = nested + ? Math.max(0, itemRow.depth) * NESTED_LINEAGE_CARD_INDENT + : 0 + const inheritedCardContentIndent = getWorktreeCardContentIndent({ + isGrouped: groupBy !== 'none', + groupDepth: itemRow.groupDepth, + lineageDepth: 0 + }) // Why: grouped rows inherit their project/group header depth, // while the card surface still spans the full hit/background row. const paddingLeft = getWorktreeCardContentIndent({ @@ -3438,6 +3365,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp groupDepth: itemRow.groupDepth, lineageDepth: paddingDepth }) + const cardContentIndent = nested ? inheritedCardContentIndent : paddingLeft const worktreeDragGroupKey = groupKeyByWorktreeId.get(itemRow.worktree.id) const worktreeDragGroupIndex = groupIndexByWorktreeId.get(itemRow.worktree.id) const revealHighlightTone = @@ -3474,7 +3402,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp nested ? undefined : handleWorktreeRowPointerDown(event, itemRow.worktree.id) } style={{ - paddingLeft: nested && paddingLeft > 0 ? `${paddingLeft}px` : undefined + paddingLeft: + nested && nestedCardPaddingLeft > 0 ? `${nestedCardPaddingLeft}px` : undefined }} > { - const isActive = activeWorktreeId === child.worktree.id - const isDeleting = deleteStateByWorktreeId[child.worktree.id]?.isDeleting ?? false - const revealHighlightTone = - agentSendTargetWorktreeId === child.worktree.id ? 'ai' : 'default' - const showStatus = cardProps.includes('status') - const showUnreadQuickAction = cardProps.includes('unread') - const unreadTooltip = child.worktree.isUnread ? 'Mark read' : 'Mark unread' - const stopQuickActionPointerPropagation = ( - event: React.PointerEvent - ) => { - event.stopPropagation() - } - const handleToggleUnreadQuick = (event: React.MouseEvent) => { - event.preventDefault() - event.stopPropagation() - onToggleWorktreeUnread(child.worktree) - } - const handleClick = (event: React.MouseEvent) => { - event.preventDefault() - event.stopPropagation() - if (isDeleting) { - return + const renderLineageDescendants = ( + parent: WorktreeItemRow, + descendants: readonly WorktreeItemRow[] + ): React.ReactNode | undefined => { + const childNodes: React.ReactNode[] = [] + let cursor = 0 + while (cursor < descendants.length) { + const child = descendants[cursor] + if (!child || child.depth !== parent.depth + 1) { + cursor++ + continue } - const selectionOnly = onSelectionGesture(event, child.worktree.id) - if (selectionOnly) { - return - } - onImmediateWorktreeActivate(child.worktree.id) - activateWorktreeFromSidebar(child.worktree.id) - if (child.repo?.connectionId) { - const sshStatus = - useAppStore.getState().sshConnectionStates.get(child.repo.connectionId) - ?.status ?? 'disconnected' - if (sshStatus !== 'connected') { - setLineageReconnectWorktreeId(child.worktree.id) - } + + let nextSiblingIndex = cursor + 1 + while ( + nextSiblingIndex < descendants.length && + descendants[nextSiblingIndex]!.depth > child.depth + ) { + nextSiblingIndex++ } + + const childLineageChildren = renderLineageDescendants( + child, + descendants.slice(cursor + 1, nextSiblingIndex) + ) + childNodes.push(renderWorktreeRow(child, true, childLineageChildren)) + cursor = nextSiblingIndex } - const lineageToggleGroupKey = child.lineageGroupKey - const childCardIndent = Math.max(0, child.depth) * LINEAGE_INDENT - const childContentIndent = Math.max(0, child.depth - 1) * LINEAGE_INDENT - return ( -
0 ? { paddingLeft: `${childCardIndent}px` } : undefined} - > - onContextMenuSelect(event, child.worktree)} - > -
event.stopPropagation()} - > - {isDeleting && ( -
-
- - {translate( - 'auto.components.sidebar.WorktreeList.5fc9d1891b', - 'Deleting…' - )} -
-
- )} -
0 - ? { paddingLeft: `calc(0.5rem + ${childContentIndent}px)` } - : undefined - } - > - - - -
- - updateWorktreeMeta(child.worktree.id, { displayName }) - } - beginEditing={renamingWorktreeId === child.worktree.id} - onBeginEditingConsumed={() => setRenamingWorktreeId(null)} - /> -
- {child.repo && groupBy !== 'repo' ? ( - - - - {child.repo.displayName} - - - ) : null} - - {branchDisplayName(child.worktree.branch)} - -
- {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 - ? translate( - 'auto.components.sidebar.WorktreeList.84a2238242', - 'Show child workspaces' - ) - : translate( - 'auto.components.sidebar.WorktreeList.ebc5c7dcef', - 'Hide child workspaces' - )} - - -
- ) : null} -
-
-
-
-
- ) + return childNodes.length > 0 ? childNodes : undefined } if (row.type === 'lineage-group') { @@ -3769,9 +3507,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? renderWorktreeRow( parent, false, - children.length > 0 - ? children.map((child) => renderLineageChildCard(child)) - : undefined, + renderLineageDescendants(parent, children), childIsActive ) : null} @@ -4710,13 +4446,6 @@ const WorktreeList = React.memo(function WorktreeList({ [updateWorktreeMeta, worktreeMap, workspaceStatuses] ) - const toggleWorktreeUnread = useCallback( - (worktree: Worktree) => { - void updateWorktreeMeta(worktree.id, { isUnread: !worktree.isUnread }) - }, - [updateWorktreeMeta] - ) - const moveWorktreesToStatus = useCallback( (worktreeIds: readonly string[], status: WorkspaceStatus) => { const updates = new Map() @@ -5058,7 +4787,6 @@ const WorktreeList = React.memo(function WorktreeList({ selectedWorktrees={selectedWorktrees} onSelectionGesture={updateSelectionForGesture} onImmediateWorktreeActivate={handleImmediateWorktreeActivate} - onToggleWorktreeUnread={toggleWorktreeUnread} onContextMenuSelect={selectForContextMenu} repoMap={repoMap} worktreeMap={worktreeMap} @@ -5076,7 +4804,6 @@ const WorktreeList = React.memo(function WorktreeList({ onDropWorktreesOnWorkspaceBoard={dropWorktreesOnWorkspaceBoard} shouldShowWorkspaceBoardDropIndicator={shouldShowWorkspaceBoardDropIndicator} onReorderWorktrees={reorderWorktrees} - showInlineAgentCards={cardProps.includes('inline-agents')} scrollOffsetRef={scrollOffsetRef} scrollAnchorRef={scrollAnchorRef} />