From 6d2646df1e8ab091e59caf63e646f32d0e784e4b 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 001/297] 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 | 448 ++++++------------ 4 files changed, 675 insertions(+), 357 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 d71fa68af59..ac2f6f73baf 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -488,6 +488,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. @@ -498,7 +503,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 c120f7d24d0..b7235e6e1e5 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', () => ({ @@ -206,6 +256,7 @@ function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage { function setLineageFixtureState( groupBy: 'none' | 'repo' = 'none', options: { + childWorktreeOverrides?: Partial deletingWorktreeIds?: string[] projectGrouped?: boolean unreadWorktreeIds?: string[] @@ -241,6 +292,7 @@ function setLineageFixtureState( branch: 'child-branch', sortOrder: 20 }) + Object.assign(child, options.childWorktreeOverrides) const grandchild = makeWorktree({ id: 'grandchild', instanceId: 'grandchild-instance', @@ -453,6 +505,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 @@ -492,63 +556,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' @@ -558,17 +635,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') @@ -578,23 +656,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 f3ac35f8cf7..4a76cee05c3 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -12,12 +12,10 @@ import { Eye, FolderInput, FolderPlus, - Loader2, Plus, Shapes, SlidersHorizontal, - Trash2, - Workflow + Trash2 } from 'lucide-react' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' @@ -30,12 +28,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 { @@ -117,14 +110,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, @@ -176,7 +167,6 @@ import { pruneWorktreeSelection, updateWorktreeSelection } from './worktree-multi-selection' -import { branchDisplayName } from './WorktreeCardHelpers' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getRepoHeaderCreateState } from './repo-header-create-state' import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' @@ -191,7 +181,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, @@ -362,10 +351,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: Row[] @@ -396,7 +385,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 @@ -439,7 +427,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. @@ -744,7 +731,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp selectedWorktrees, onSelectionGesture, onImmediateWorktreeActivate, - onToggleWorktreeUnread, onContextMenuSelect, repoMap, worktreeMap, @@ -762,7 +748,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onDropWorktreesOnWorkspaceBoard, shouldShowWorkspaceBoardDropIndicator, onReorderWorktrees, - showInlineAgentCards, scrollOffsetRef, scrollAnchorRef }: VirtualizedWorktreeViewportProps) { @@ -771,7 +756,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 ) @@ -780,9 +764,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) @@ -851,7 +833,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( @@ -1015,41 +996,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( @@ -1536,16 +1483,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') { @@ -2753,7 +2690,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp data-worktree-sidebar tabIndex={0} role="listbox" - aria-label={translate("auto.components.sidebar.WorktreeList.bfbedc547b", "Worktrees")} + aria-label={translate('auto.components.sidebar.WorktreeList.bfbedc547b', 'Worktrees')} aria-orientation="vertical" aria-multiselectable="true" aria-activedescendant={activeDescendantId} @@ -2770,24 +2707,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}
event.stopPropagation()} onKeyDown={stopRepoHeaderKeyboardToggle} onPointerDown={handleRepoHeaderActionPointerDown} @@ -3035,7 +2958,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } }} > - {translate("auto.components.sidebar.WorktreeList.4d7b73658c", "Rename group")} + {translate( + 'auto.components.sidebar.WorktreeList.4d7b73658c', + 'Rename group' + )} + { @@ -3044,12 +2971,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } }} > - {translate("auto.components.sidebar.WorktreeList.902115cdbe", "Delete group")} + {translate( + 'auto.components.sidebar.WorktreeList.902115cdbe', + 'Delete group' + )} + ) : null} - {row.repo && groupBy === "repo" ? ( + {row.repo && groupBy === 'repo' ? ( @@ -3060,7 +2991,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp size="icon-xs" data-repo-header-action="" className="size-5 shrink-0 rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent/70 hover:text-foreground focus:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100" - aria-label={translate("auto.components.sidebar.WorktreeList.609633a9e6", "Project actions for {{value0}}", { value0: row.label })} + aria-label={translate( + 'auto.components.sidebar.WorktreeList.609633a9e6', + 'Project actions for {{value0}}', + { value0: row.label } + )} onClick={(event) => event.stopPropagation()} onKeyDown={stopRepoHeaderKeyboardToggle} onPointerDown={handleRepoHeaderActionPointerDown} @@ -3070,7 +3005,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp - {translate("auto.components.sidebar.WorktreeList.2ef41bf9a7", "Project actions")} + {translate( + 'auto.components.sidebar.WorktreeList.2ef41bf9a7', + 'Project actions' + )} + - {translate("auto.components.sidebar.WorktreeList.2cdffbc728", "Project Settings")} + {translate( + 'auto.components.sidebar.WorktreeList.2cdffbc728', + 'Project Settings' + )} + { if (row.repo) { @@ -3106,7 +3049,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }} > - {translate("auto.components.sidebar.WorktreeList.e82d3589a1", "Change Project Icon")} + {translate( + 'auto.components.sidebar.WorktreeList.e82d3589a1', + 'Change Project Icon' + )} + {row.repo && isGitRepoKind(row.repo) ? ( { @@ -3127,12 +3074,20 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }} > - {translate("auto.components.sidebar.WorktreeList.cbfd565f83", "New group from project")} + {translate( + 'auto.components.sidebar.WorktreeList.cbfd565f83', + 'New group from project' + )} + {projectGroups.length > 0 ? ( - {translate("auto.components.sidebar.WorktreeList.4a08fb55f2", "Move to group")} + {translate( + 'auto.components.sidebar.WorktreeList.4a08fb55f2', + 'Move to group' + )} + {projectGroups.map((group) => ( - {translate("auto.components.sidebar.WorktreeList.64e55f7f01", "Remove from group")} + {translate( + 'auto.components.sidebar.WorktreeList.64e55f7f01', + 'Remove from group' + )} + ) : null} - {translate("auto.components.sidebar.WorktreeList.c83968f87f", "Remove Project")} + {translate( + 'auto.components.sidebar.WorktreeList.c83968f87f', + 'Remove Project' + )} + ) : null} - {row.repo && groupBy === "repo" ? ( + {row.repo && groupBy === 'repo' ? ( {createState?.disabled ? ( @@ -3208,7 +3171,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp data-repo-header-action="" className="size-5 shrink-0 rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent/70 hover:text-foreground focus:opacity-100 group-hover:opacity-100" aria-label={ - createState?.ariaLabel ?? translate("auto.components.sidebar.WorktreeList.bb85cd86ba", "Create workspace for {{value0}}", { value0: row.label }) + createState?.ariaLabel ?? + translate( + 'auto.components.sidebar.WorktreeList.bb85cd86ba', + 'Create workspace for {{value0}}', + { value0: row.label } + ) } onKeyDown={stopRepoHeaderKeyboardToggle} onClick={(event) => { @@ -3224,7 +3192,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp )} - {createState?.tooltip ?? translate("auto.components.sidebar.WorktreeList.bb85cd86ba", "Create workspace for {{value0}}", { value0: row.label })} + {createState?.tooltip ?? + translate( + 'auto.components.sidebar.WorktreeList.bb85cd86ba', + 'Create workspace for {{value0}}', + { value0: row.label } + )} ) : null} @@ -3240,9 +3213,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp forceActiveSurface = false ) => { 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({ @@ -3250,6 +3231,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 = @@ -3286,7 +3268,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') { @@ -3554,9 +3374,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? renderWorktreeRow( parent, false, - children.length > 0 - ? children.map((child) => renderLineageChildCard(child)) - : undefined, + renderLineageDescendants(parent, children), childIsActive ) : null} @@ -4456,13 +4274,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() @@ -4711,14 +4522,17 @@ const WorktreeList = React.memo(function WorktreeList({ >
- {translate("auto.components.sidebar.WorktreeList.b7acbf038b", "No workspaces found")} + + {translate('auto.components.sidebar.WorktreeList.b7acbf038b', 'No workspaces found')} + {hasFilters && ( + {translate('auto.components.sidebar.WorktreeList.370c6a55dd', 'Clear Filters')} + )}
@@ -4731,12 +4545,20 @@ const WorktreeList = React.memo(function WorktreeList({ From 93e59ab0867a81d93c74b6e341aa53a7260febd4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:55:20 -0400 Subject: [PATCH 002/297] fix(mobile): recover Android remote sessions without an app restart (#5061) Co-authored-by: Orca --- mobile/app/h/[hostId]/files/[worktreeId].tsx | 25 +-- .../app/h/[hostId]/session/[worktreeId].tsx | 40 +++- .../[hostId]/source-control/[worktreeId].tsx | 30 +-- ...ssue-5049-unresponsive-session-findings.md | 92 ++++++++ mobile/package.json | 1 + mobile/pnpm-lock.yaml | 30 ++- mobile/src/session/worktree-label.ts | 12 + mobile/src/transport/client-context.tsx | 29 ++- .../connection-revival-triggers.test.ts | 94 ++++++++ .../transport/connection-revival-triggers.ts | 50 +++++ .../rpc-client-live-recovery.test.ts | 208 ++++++++++++++++++ mobile/src/transport/rpc-client.test.ts | 114 ++++++++++ mobile/src/transport/rpc-client.ts | 208 ++++++++---------- mobile/src/transport/socket-event-debug.ts | 34 +++ 14 files changed, 798 insertions(+), 169 deletions(-) create mode 100644 mobile/issue-5049-unresponsive-session-findings.md create mode 100644 mobile/src/session/worktree-label.ts create mode 100644 mobile/src/transport/connection-revival-triggers.test.ts create mode 100644 mobile/src/transport/connection-revival-triggers.ts create mode 100644 mobile/src/transport/rpc-client-live-recovery.test.ts create mode 100644 mobile/src/transport/socket-event-debug.ts diff --git a/mobile/app/h/[hostId]/files/[worktreeId].tsx b/mobile/app/h/[hostId]/files/[worktreeId].tsx index 1f3d620d068..8180baafb81 100644 --- a/mobile/app/h/[hostId]/files/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/[worktreeId].tsx @@ -11,7 +11,8 @@ import { import { SafeAreaView } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter } from 'expo-router' import { ChevronDown, ChevronLeft, ChevronRight, File, FileText, Folder } from 'lucide-react-native' -import { useHostClient } from '../../../../src/transport/client-context' +import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context' +import { getWorktreeLabel } from '../../../../src/session/worktree-label' import type { RpcSuccess } from '../../../../src/transport/types' import { triggerError, triggerSelection } from '../../../../src/platform/haptics' import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme' @@ -104,17 +105,6 @@ function isMarkdownPath(relativePath: string): boolean { return /\.(md|mdx|markdown)$/i.test(relativePath) } -function getWorktreeLabel(name: string | undefined, worktreeId: string): string { - if (name?.trim()) { - return name.trim() - } - const pathPart = worktreeId.includes('::') - ? worktreeId.slice(worktreeId.indexOf('::') + 2) - : worktreeId - const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '') - return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree' -} - export default function MobileFileExplorerScreen() { const { hostId, worktreeId, name } = useLocalSearchParams<{ hostId: string @@ -123,6 +113,7 @@ export default function MobileFileExplorerScreen() { }>() const router = useRouter() const { client, state: connState } = useHostClient(hostId) + const forceReconnect = useForceReconnect() const [files, setFiles] = useState([]) const [expanded, setExpanded] = useState>(() => new Set()) const [loading, setLoading] = useState(true) @@ -287,7 +278,15 @@ export default function MobileFileExplorerScreen() { ) : error ? ( {error} - void loadFiles()}> + {/* Why: while disconnected, re-sending the request is useless — revive + the parked transport instead (issue #5049); loadFiles re-runs via + its effect once the new client connects. */} + + connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadFiles() + } + > Retry diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index a9cc8eb7075..c5d8fb5527e 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -46,7 +46,13 @@ import { } from 'lucide-react-native' import type { RpcClient } from '../../../../src/transport/rpc-client' import { loadHosts } from '../../../../src/transport/host-store' -import { useHostClient } from '../../../../src/transport/client-context' +import { + useHostClient, + useForceReconnect, + useReconnectAttempt, + useLastConnectedAt +} from '../../../../src/transport/client-context' +import { classifyConnection } from '../../../../src/transport/connection-health' import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types' import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation' import { @@ -924,6 +930,9 @@ export default function SessionScreen() { // Why: shared client per host owned by RpcClientProvider. See // docs/mobile-shared-client-per-host.md. const { client, state: connState } = useHostClient(hostId) + const reconnectAttempts = useReconnectAttempt(hostId) + const lastConnectedAt = useLastConnectedAt(hostId) + const forceReconnectHost = useForceReconnect() const initialCreateWarning = typeof createdWarning === 'string' ? createdWarning.trim() : '' const [terminals, setTerminals] = useState([]) const terminalsRef = useRef([]) @@ -3640,6 +3649,17 @@ export default function SessionScreen() { void handleCreateTerminal() }, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId]) + // Why: the reconnect loop parks at its give-up cap; without an in-session + // affordance the only recovery is leaving the screen or restarting the + // app (issue #5049). Surface tap-to-retry once the verdict escalates. + const connectionVerdict = classifyConnection({ + state: connState, + reconnectAttempts, + lastConnectedAt + }) + const showConnectionRetry = + connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable' + const terminalSummary = connState === 'connected' ? showLoadingState @@ -3647,7 +3667,9 @@ export default function SessionScreen() { : visibleTabs.length === 1 ? '1 tab' : `${visibleTabs.length} tabs` - : STATUS_LABELS[connState] + : showConnectionRetry + ? `${connectionVerdict.label} — tap to retry` + : STATUS_LABELS[connState] // Why: keep safe-area padding in layout at all times, then visually translate // the controls over the terminal when the keyboard appears. iOS keyboard @@ -3790,12 +3812,22 @@ export default function SessionScreen() { {worktreeName || 'Terminal'} - + { + if (hostId) { + void forceReconnectHost(hostId) + } + }} + accessibilityRole={showConnectionRetry ? 'button' : undefined} + accessibilityLabel={showConnectionRetry ? 'Reconnect to desktop' : undefined} + > {terminalSummary} - + [styles.filesButton, pressed && styles.filesButtonPressed]} diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx index dad4d4cfe94..59ea98736b3 100644 --- a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx @@ -30,7 +30,8 @@ import { Trash2, X } from 'lucide-react-native' -import { useHostClient } from '../../../../src/transport/client-context' +import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context' +import { getWorktreeLabel } from '../../../../src/session/worktree-label' import type { RpcClient } from '../../../../src/transport/rpc-client' import type { RpcSuccess } from '../../../../src/transport/types' import { @@ -200,17 +201,6 @@ async function resolveMobileBranchCompareBaseRef( return result.defaultBaseRef?.trim() || null } -function getWorktreeLabel(name: string | undefined, worktreeId: string): string { - if (name?.trim()) { - return name.trim() - } - const pathPart = worktreeId.includes('::') - ? worktreeId.slice(worktreeId.indexOf('::') + 2) - : worktreeId - const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '') - return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree' -} - function formatBranchLabel(branch: string | undefined, head: string | undefined): string { if (branch?.startsWith('refs/heads/')) { return branch.slice('refs/heads/'.length) @@ -249,6 +239,7 @@ export default function MobileSourceControlScreen() { const router = useRouter() const insets = useSafeAreaInsets() const { client, state: connState } = useHostClient(hostId) + const forceReconnect = useForceReconnect() const [screenState, setScreenState] = useState({ kind: 'loading' }) const [branchCompareState, setBranchCompareState] = useState({ kind: 'idle' @@ -1431,7 +1422,20 @@ export default function MobileSourceControlScreen() { {screenState.message} {screenState.kind === 'error' ? ( - void loadStatus()}> + { + // Why: retrying the request is useless while the transport's + // reconnect loop is parked at its give-up cap — revive the + // connection instead (issue #5049). loadStatus re-runs via + // its connState effect once the new client connects. + if (connState !== 'connected' && hostId) { + void forceReconnect(hostId) + return + } + void loadStatus() + }} + > Retry ) : null} diff --git a/mobile/issue-5049-unresponsive-session-findings.md b/mobile/issue-5049-unresponsive-session-findings.md new file mode 100644 index 00000000000..6440e1c990c --- /dev/null +++ b/mobile/issue-5049-unresponsive-session-findings.md @@ -0,0 +1,92 @@ +# Issue #5049: Android Remote Session Unresponsiveness — Findings + +Date: 2026-06-09 +Issue: https://github.com/stablyai/orca/issues/5049 + +## Reported symptoms + +Android + Tailscale remote session intermittently becomes unresponsive: tab/worktree +taps do nothing, pasted text doesn't execute, the connection "appears stuck instead +of clearly disconnected", and closing/reopening the app restores the session. + +## Root causes found (mobile-side) + +All three independently produce the exact reported symptom — a session that looks +alive but ignores input, recoverable only by an app restart: + +1. **Parked reconnect loop with no recovery path (primary).** `rpc-client.ts` + stops retrying permanently after `GIVE_UP_AFTER_ATTEMPTS` (12 attempts ≈ 6.5 min + of backoff). Android backgrounding + Doze + a Tailscale tunnel drop routinely + burns through all 12 attempts while the user is away. Nothing ever restarted the + loop: there was **no AppState listener anywhere in the transport layer**, so + returning to the foreground did not nudge the client. The state stays + `'reconnecting'` forever ("appears stuck instead of clearly disconnected"). + Reopening the app creates a fresh client with a fresh attempt budget — which is + exactly why "closing and reopening usually restores the session". + +2. **Half-open socket detection waits up to ~28s, and never starts earlier on + resume.** Android can kill the TCP path while backgrounded without delivering + `onclose`; `readyState` still reads OPEN, so every `terminal.send` (e.g. paste) + silently blackholes. The activity probe (20s interval + 8s timeout) eventually + reaps the link, but the first ~28s after resume look like "pasted text does not + run immediately" / "switching is very slow". + +3. **Stale client after `forceReconnect` (pre-existing `useHostClient` bug).** + `forceReconnect` swaps in a fresh `RpcClient`, but `useHostClient` only re-read + the client when its ref was still `null`. Any mounted screen kept driving the + old, **closed** client forever: the status header (fed by provider-level state + listeners) shows "Connected" while every RPC instantly fails with "Client + closed" — a session that looks alive but ignores all input. + + Additionally, the session screen (where users actually live) had no recovery + affordance at all: just a status label, while the Retry buttons exist only on + the home/host/tasks screens. + +## Fixes + +- `src/transport/rpc-client.ts` — new `notifyForeground()`: + - state `connected` → restart the probe interval and run one probe immediately + (half-open link reaped in ≤8s instead of ≤28s); + - state `reconnecting` → clear any pending backoff timer, reset the attempt + budget, reconnect immediately (un-parks the give-up cap). + - (Also extracted the duplicated close/error event serialization into + `socket-event-debug.ts` to stay under the file's line cap.) +- `src/transport/client-context.tsx`: + - `RpcClientProvider` now listens to AppState and calls `notifyForeground()` on + every live client when the app becomes active. + - `useHostClient` re-reads the underlying client on every state change, so + screens pick up the fresh client after `forceReconnect` instead of driving a + closed one. +- `app/h/[hostId]/session/[worktreeId].tsx` — the status row in the session header + becomes tappable once `classifyConnection` escalates to warning/unreachable, + showing "