From d280a7cbe8d3d34178e1f8fa7be6ca0afd881551 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:50:03 -0700 Subject: [PATCH 01/17] Make sidebar reveal always jump instantly instead of smooth-scrolling (#8019) - Removes the `behavior`/`sidebarRevealBehavior` plumbing throughout activation and reveal call sites now that every reveal jumps immediately, eliminating the need to special-case newly created worktrees. - Reworks worktree-sidebar-reveal.ts to center the target row within the viewport and temporarily pad list boundaries so first/last rows can still center instead of clamping to the edge. - Drops the reduced-motion e2e workaround since reveals no longer animate. --- docs/new-worktree-sidebar-reveal.md | 79 ----------------- .../src/components/WorktreeJumpPalette.tsx | 2 +- .../components/sidebar/NonGitFolderDialog.tsx | 5 +- .../sidebar/ProjectAddedDialog.test.tsx | 4 +- .../components/sidebar/ProjectAddedDialog.tsx | 2 +- .../components/sidebar/WorktreeCardAgents.tsx | 4 +- .../src/components/sidebar/WorktreeList.tsx | 85 ++++++++++++------- .../useCreateRepo.default-checkout.test.ts | 4 +- .../src/components/sidebar/useCreateRepo.ts | 2 +- .../worktree-list-scroll-adjustment.test.ts | 68 ++++++++------- .../worktree-scroll-to-current-button.test.ts | 14 +-- .../sidebar/worktree-sidebar-reveal.ts | 62 ++++++++------ .../terminal-agent-session-fork.test.ts | 8 +- .../terminal-agent-session-fork.ts | 4 +- src/renderer/src/hooks/useComposerState.ts | 1 - src/renderer/src/hooks/useIpcEvents.test.ts | 1 - src/renderer/src/hooks/useIpcEvents.ts | 3 - .../src/lib/launch-work-item-direct.ts | 2 - .../worktree-activation-created-agent.test.ts | 4 +- src/renderer/src/lib/worktree-activation.ts | 15 +--- .../src/lib/worktree-creation-flow.ts | 1 - .../repos-onboarding-folder-startup.test.ts | 3 +- src/renderer/src/store/slices/repos.ts | 5 +- src/renderer/src/store/slices/ui.test.ts | 3 - src/renderer/src/store/slices/ui.ts | 8 +- .../src/store/slices/worktrees.test.ts | 6 +- src/renderer/src/store/slices/worktrees.ts | 2 +- tests/e2e/worktree-scroll-to-current.spec.ts | 8 +- .../worktree-switch-responsiveness.spec.ts | 4 +- 29 files changed, 161 insertions(+), 248 deletions(-) delete mode 100644 docs/new-worktree-sidebar-reveal.md diff --git a/docs/new-worktree-sidebar-reveal.md b/docs/new-worktree-sidebar-reveal.md deleted file mode 100644 index 75abb7bd647..00000000000 --- a/docs/new-worktree-sidebar-reveal.md +++ /dev/null @@ -1,79 +0,0 @@ -# New Worktree Sidebar Reveal - -## Problem - -Issue https://github.com/stablyai/orca-internal/issues/350 asks that newly created worktrees jump into view in the left sidebar list with no scroll animation. - -Current behavior: - -- `activateAndRevealWorktree(...)` always calls `state.revealWorktreeInSidebar(worktreeId)` with no options. -- `revealWorktreeInSidebar` defaults `behavior` to `'smooth'` (`ui.ts`). -- `WorktreeList` forwards that behavior to `virtualizer.scrollToIndex(..., { behavior })`. - -Result: off-screen targets animate by default, including freshly created worktrees. - -## Root Cause - -`activateAndRevealWorktree` conflates two intents: - -1. activate existing worktree navigation (smooth reveal is fine); -2. activate a just-created worktree (must jump immediately). - -Created-worktree callers cannot currently express reveal intent, so they inherit `'smooth'`. - -## Scope and Non-goals - -- Add an opt-in reveal behavior at activation call sites. -- Apply `'auto'` only where the worktree is newly created/added in that flow. -- Preserve existing behavior for normal worktree navigation (clicks, keyboard, history nav, palette selection of existing worktrees, port/status-driven activation) unless a caller opts in. -- Do not change direct raw reveal paths in IPC handlers that intentionally call `store.revealWorktreeInSidebar(...)` outside `activateAndRevealWorktree` (terminal/editor/mobile focus paths remain smooth). -- Do not change sorting/grouping/filter UI, list virtualization strategy, or sidebar styling. - -## Design - -1. Extend `activateAndRevealWorktree` options: - - `sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior']`. -2. In `activateAndRevealWorktree`, call: - - `state.revealWorktreeInSidebar(worktreeId, { behavior: opts.sidebarRevealBehavior })` when provided; - - otherwise keep `state.revealWorktreeInSidebar(worktreeId)` so default behavior stays unchanged. -3. Pass `sidebarRevealBehavior: 'auto'` only from created/added-worktree flows: - - `useComposerState` full-create path; - - `useComposerState` quick-create path; - - `useIpcEvents` `onActivateWorktree` only when the event corresponds to a newly created worktree; - - `launch-work-item-direct`; - - folder add/create flows that activate a newly-added synthetic folder worktree (`AddRepoCreateStep` folder branch, `NonGitFolderDialog`, and `repos` slice `addNonGitFolder` path). -4. Keep existing navigation activations smooth, including: - - `AddRepoDialog` / `ProjectAddedDialog` “open primary worktree” actions (these can target pre-existing worktrees, not guaranteed newly created); - - all existing `activateAndRevealWorktree(...)` callers that do not opt in. -5. Keep `WorktreeList` reveal effect unchanged; it already honors `pendingRevealWorktree.behavior`. - -## Correctness Notes and Edge Cases - -- Repo-filter clearing remains unchanged: `activateAndRevealWorktree` only clears `filterRepoIds` when target repo is excluded. -- Other visibility constraints are not auto-cleared. If the target exists but is hidden by other sidebar state, `resolvePendingSidebarReveal(...)` keeps the reveal pending. -- The reveal effect uncollapses lineage/group containers before scroll; behavior changes only animation mode, not visibility resolution. -- Pending reveal is a single store slot (`pendingRevealWorktree`). Concurrent reveal requests are last-writer-wins; this change should not alter that behavior. -- If activation cannot resolve the worktree (`getKnownWorktreeById` miss), behavior remains unchanged (`false`, no reveal queued). -- `ui:activateWorktree` is an overloaded IPC used by both creation and non-creation activation paths. The renderer must choose `'auto'` only for create cases (for example, worktree absent before fetch and present after fetch), and keep default smooth reveal for existing-worktree activations. -- Multi-window consistency remains per renderer window store; each window applies its own reveal behavior locally. -- This change is renderer-only; it does not add main-process coordination and does not make reveal ordering transactional across concurrent async creators. - -## Tests - -Add/adjust focused tests in `worktree-activation` coverage: - -- explicit `sidebarRevealBehavior: 'auto'` is forwarded to `revealWorktreeInSidebar(worktreeId, { behavior: 'auto' })`; -- no option still calls `revealWorktreeInSidebar(worktreeId)` (store default remains smooth). - -Add call-site regression tests (recommended, small): - -- one composer create path passes `'auto'`; -- one non-created navigation path stays default (no behavior option). - -## Rollout - -1. Add `sidebarRevealBehavior` option in `activateAndRevealWorktree`. -2. Update created-worktree callers to pass `'auto'`. -3. Add tests above. -4. Run targeted Vitest tests, then `pnpm typecheck` and `pnpm lint`. -5. Validate in Electron: with sidebar overflow, create a worktree and verify the list jumps to it without smooth animation. diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 0b18f5f3f09..1cfe10cf9b7 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -1409,7 +1409,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { skipRestoreFocusRef.current = true // Why: selecting a project or repo group is a sidebar navigation action; // it should reveal the grouping row without activating an arbitrary workspace. - revealSidebarRow(result.rowKey, { behavior: 'smooth', highlight: true }) + revealSidebarRow(result.rowKey, { highlight: true }) recordFeatureInteraction('cmd-j') closeModal() setSelectedItemId('') diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 5ec3354ec22..33de6b6eb1d 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -82,10 +82,7 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { onboarding, hadProjectBeforeAdd ) - activateAndRevealWorktree(folderWorktree.id, { - sidebarRevealBehavior: 'auto', - ...(startup ? { startup } : {}) - }) + activateAndRevealWorktree(folderWorktree.id, startup ? { startup } : undefined) } } catch (err) { // This code path calls addRemote directly (not through the store), diff --git a/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx b/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx index f3580472439..66409526770 100644 --- a/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx +++ b/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx @@ -145,9 +145,7 @@ describe('ProjectAddedDialog', () => { expect(markup).toBe('') expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith('repo-1') - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo-1::folder', { - sidebarRevealBehavior: 'auto' - }) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo-1::folder') expect(mocks.state.closeModal).toHaveBeenCalledTimes(1) expect(mocks.finishProjectAddWithDefaultCheckout).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx b/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx index 6532a39cae8..34f53ffd6ac 100644 --- a/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx +++ b/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx @@ -81,7 +81,7 @@ export default function ProjectAddedDialog(): null { } const folderWorktree = useAppStore.getState().worktreesByRepo[repoId]?.[0] if (folderWorktree) { - activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto' }) + activateAndRevealWorktree(folderWorktree.id) } closeModal() })() diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 7bfda38d231..033cb286dfa 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -36,7 +36,7 @@ function revealCompactAgentCard(agentListRoot: HTMLElement | null): void { if (!(sidebarElement instanceof HTMLElement) || !worktreeOptionElement) { return } - revealElementInScrollContainer(sidebarElement, worktreeOptionElement, 'auto') + revealElementInScrollContainer(sidebarElement, worktreeOptionElement) } type Props = { @@ -238,7 +238,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ // Why: defer the reveal scroll out of the expand commit. Running it inline // forces a synchronous sidebar layout that blocks the animation's opening // frames (a visible jump); next-frame keeps the open smooth and the - // ScrollBehavior 'auto' still lands before the height transition finishes. + // instant reveal still lands before the height transition finishes. const handle = requestAnimationFrame(() => { revealCompactAgentCard(compactAgentListRootRef.current) }) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 7b8aaab26d3..34725365a37 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -100,6 +100,7 @@ import { type RenderRow } from './worktree-list-virtual-rows' import { + getElementCenteringScrollPadding, revealElementInScrollContainer, WORKTREE_SIDEBAR_REVEAL_TOP_INSET } from './worktree-sidebar-reveal' @@ -287,6 +288,7 @@ import { import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' export { + getCenteringScrollPadding, getScrollTopToRevealBounds, WORKTREE_SIDEBAR_REVEAL_TOP_INSET } from './worktree-sidebar-reveal' @@ -470,31 +472,15 @@ function markSidebarWorktreeActiveImmediately(worktreeId: string, primaryRowKey? } } -function revealMountedWorktreeElement( - container: HTMLElement, - worktreeId: string, - behavior: ScrollBehavior, - optionId?: string -): HTMLElement | null { - const element = optionId - ? document.getElementById(optionId) - : getMountedWorktreeOptions(worktreeId, container)[0] - if (!element || !container.contains(element)) { - return null - } - return revealElementInScrollContainer(container, element, behavior) ? element : null -} - function revealMountedSidebarRowElement( container: HTMLElement, - rowKey: string, - behavior: ScrollBehavior + rowKey: string ): HTMLElement | null { const element = document.getElementById(getWorktreeOptionId(rowKey)) if (!element || !container.contains(element)) { return null } - return revealElementInScrollContainer(container, element, behavior) ? element : null + return revealElementInScrollContainer(container, element) ? element : null } function getRenderRowSidebarKey(row: RenderRow): string | null { @@ -1336,6 +1322,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const [worktreeDragState, setWorktreeDragState] = useState( WORKTREE_ROW_DRAG_INITIAL_STATE ) + const [centeringScrollPadding, setCenteringScrollPadding] = useState<{ + targetKey: string + start: number + end: number + } | null>(null) const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0) const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0) const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) @@ -1985,6 +1976,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ), overscan: 10, gap: 6, + paddingStart: centeringScrollPadding?.start ?? 0, + paddingEnd: centeringScrollPadding?.end ?? 0, // Why: the active sticky group header is rendered inside the virtual list, // so TanStack's scroll math needs the same top inset as the exact DOM reveal. scrollPaddingStart: WORKTREE_SIDEBAR_REVEAL_TOP_INSET, @@ -2132,14 +2125,35 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp clearPendingRevealWorktreeId() } } - const revealedOption = container - ? revealMountedWorktreeElement( - container, - pendingRevealWorktree.worktreeId, - pendingRevealWorktree.behavior, - getRenderRowOptionId(targetRow, pendingRevealWorktree.worktreeId) - ) - : null + const optionId = getRenderRowOptionId(targetRow, pendingRevealWorktree.worktreeId) + const centeringTargetKey = `worktree:${optionId ?? pendingRevealWorktree.worktreeId}` + if (centeringScrollPadding && centeringScrollPadding.targetKey !== centeringTargetKey) { + setCenteringScrollPadding(null) + retryExactRevealOnNextFrame() + return + } + const option = optionId + ? document.getElementById(optionId) + : getMountedWorktreeOptions(pendingRevealWorktree.worktreeId, container)[0] + const mountedOption = container && option && container.contains(option) ? option : null + if (container && mountedOption) { + const additionalPadding = getElementCenteringScrollPadding(container, mountedOption) + if (additionalPadding && (additionalPadding.start > 0 || additionalPadding.end > 0)) { + // Why: the first and last virtual rows need temporary boundary space + // or the browser clamps their centered position to the list edge. + setCenteringScrollPadding({ + targetKey: centeringTargetKey, + start: (centeringScrollPadding?.start ?? 0) + additionalPadding.start, + end: (centeringScrollPadding?.end ?? 0) + additionalPadding.end + }) + retryExactRevealOnNextFrame() + return + } + } + const revealedOption = + container && mountedOption && revealElementInScrollContainer(container, mountedOption) + ? mountedOption + : null if (revealedOption) { if (pendingRevealWorktree.highlight) { const revealedRowKey = @@ -2211,6 +2225,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pendingRevealRetryTick, flashRevealedRow, setRenamingWorktreeId, + centeringScrollPadding, schedulePendingRevealFrame, cancelPendingRevealFrames ]) @@ -2294,12 +2309,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } const container = scrollRef.current + // Why: only clear stale boundary padding here. While a worktree reveal is + // still pending, clearing would wipe the padding it just accumulated and + // its boundary target could exhaust retries without ever centering. + if (centeringScrollPadding && !pendingRevealWorktree) { + setCenteringScrollPadding(null) + retryExactRevealOnNextFrame() + return + } const revealedElement = container - ? revealMountedSidebarRowElement( - container, - pendingRevealSidebarRow.rowKey, - pendingRevealSidebarRow.behavior - ) + ? revealMountedSidebarRowElement(container, pendingRevealSidebarRow.rowKey) : null if (revealedElement) { if (pendingRevealSidebarRow.highlight) { @@ -2323,6 +2342,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } }, [ pendingRevealSidebarRow, + pendingRevealWorktree, repoMap, projectGroups, projectGrouping, @@ -2334,6 +2354,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pendingRevealRetryTick, flashRevealedRow, clearPendingRevealSidebarRow, + centeringScrollPadding, schedulePendingRevealFrame, cancelPendingRevealFrames ]) @@ -6657,7 +6678,6 @@ const WorktreeList = React.memo(function WorktreeList({ { target: { type: 'sidebar-row' } } > revealSidebarRow(detail.target.rowKey, { - behavior: 'smooth', highlight: sidebarDetail.highlight !== false }) return @@ -6679,7 +6699,6 @@ const WorktreeList = React.memo(function WorktreeList({ clearFilters() } revealWorktreeInSidebar(currentSidebarWorktreeId, { - behavior: 'smooth', highlight: true, beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true }) diff --git a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts index 51a5ad755b5..83caaaa6d15 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts @@ -213,9 +213,7 @@ describe('useCreateRepo default-checkout handoff', () => { kind: 'git' }) expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id) - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(worktree.id, { - sidebarRevealBehavior: 'auto' - }) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(worktree.id) expect(mocks.markOnboardingProjectAdded).toHaveBeenCalledWith('addedFolder') expect(closeModal).toHaveBeenCalled() expect(mocks.onGitRepoReady).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts index abf35db7e66..eb42f347e6f 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.ts @@ -190,7 +190,7 @@ export function useCreateRepo( } const folderWorktree = useAppStore.getState().worktreesByRepo[repo.id]?.[0] if (folderWorktree) { - activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto' }) + activateAndRevealWorktree(folderWorktree.id) } await markOnboardingProjectAdded('addedFolder') closeModal() diff --git a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts index 924a4edc88a..f9a5a5f62ce 100644 --- a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { countRecordKeysByReference, + getCenteringScrollPadding, getScrollTopToRevealBounds, resolvePendingSidebarReveal, WORKTREE_SIDEBAR_REVEAL_TOP_INSET, @@ -43,8 +44,11 @@ const makeImportedCardRow = (): Extract - ({ scrollTop, clientHeight }) as HTMLElement +const makeScrollContainer = ( + scrollTop: number, + clientHeight: number, + scrollHeight = 1_000 +): HTMLElement => ({ scrollTop, clientHeight, scrollHeight }) as HTMLElement describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => { it('counts record keys once per object reference', () => { @@ -121,64 +125,64 @@ describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => { }) describe('getScrollTopToRevealBounds', () => { - it('treats the sticky header as occluding the viewport top', () => { - const container = makeScrollContainer(100, 400) + it('requests leading space when the first target would be clamped above center', () => { + const container = makeScrollContainer(0, 400) expect( - getScrollTopToRevealBounds( + getCenteringScrollPadding( container, { - start: 100, - end: 216 - }, - GROUP_HEADER_ROW_HEIGHT - ) - ).toBe(72) - }) - - it('includes extra reveal clearance for the highlight ring', () => { - const container = makeScrollContainer(100, 400) - - expect( - getScrollTopToRevealBounds( - container, - { - start: 100, - end: 216 + start: 34, + end: 74 }, WORKTREE_SIDEBAR_REVEAL_TOP_INSET ) - ).toBe(66) + ).toEqual({ start: 163, end: 0 }) }) - it('does not scroll when the bounds are below the sticky header', () => { + it('centers a fully visible target', () => { const container = makeScrollContainer(100, 400) expect( getScrollTopToRevealBounds( container, { - start: 128, - end: 244 + start: 300, + end: 400 }, - GROUP_HEADER_ROW_HEIGHT + WORKTREE_SIDEBAR_REVEAL_TOP_INSET ) - ).toBeNull() + ).toBe(133) }) - it('keeps the viewport bottom independent of the sticky header inset', () => { + it('centers within the area below the sticky header', () => { const container = makeScrollContainer(100, 400) expect( getScrollTopToRevealBounds( + container, + { + start: 300, + end: 400 + }, + GROUP_HEADER_ROW_HEIGHT + ) + ).toBe(136) + }) + + it('requests trailing space when the last target would be clamped below center', () => { + const container = makeScrollContainer(100, 400, 500) + + expect( + getCenteringScrollPadding( container, { start: 430, - end: 520 + end: 470 }, - GROUP_HEADER_ROW_HEIGHT + WORKTREE_SIDEBAR_REVEAL_TOP_INSET ) - ).toBe(120) + ).toEqual({ start: 0, end: 133 }) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts index 933261f20a4..850fc45704d 100644 --- a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts +++ b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts @@ -8,15 +8,17 @@ describe('getScrollTopToRevealBounds', () => { clientHeight }) as HTMLElement - it('scrolls upward to reveal a mounted current workspace card above the viewport', () => { - expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 60, end: 120 })).toBe(60) + it('centers a mounted current workspace card that starts above the viewport', () => { + // Raw result may be negative; boundary reveals convert the deficit into + // temporary paddingStart instead of clamping (see getCenteringScrollPadding). + expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 60, end: 120 })).toBe(-10) }) - it('scrolls downward to reveal a mounted current workspace card below the viewport', () => { - expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 250, end: 340 })).toBe(140) + it('centers a mounted current workspace card that starts below the viewport', () => { + expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 250, end: 340 })).toBe(195) }) - it('does not scroll when the current workspace card is already fully visible', () => { - expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 125, end: 260 })).toBeNull() + it('recenters a card that is already fully visible', () => { + expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 125, end: 260 })).toBe(92.5) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts b/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts index 38b158532af..765efc2eabc 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts @@ -9,6 +9,11 @@ type SidebarRevealBounds = { end: number } +type SidebarCenteringScrollPadding = { + start: number + end: number +} + function getElementScrollBounds(container: HTMLElement, element: Element): SidebarRevealBounds { const containerRect = container.getBoundingClientRect() const elementRect = element.getBoundingClientRect() @@ -22,24 +27,40 @@ export function getScrollTopToRevealBounds( container: HTMLElement, bounds: SidebarRevealBounds, topInset = 0 -): number | null { +): number { const viewportTopInset = Math.max(0, Math.min(container.clientHeight, topInset)) - const viewportTop = container.scrollTop + viewportTopInset - const viewportBottom = container.scrollTop + container.clientHeight - if (bounds.start < viewportTop) { - return bounds.start - viewportTopInset - } - if (bounds.end > viewportBottom) { - return bounds.end - container.clientHeight - } - return null + // Why: the sticky header reduces the usable viewport, so center within the + // visible area below it instead of behind it. + const targetCenter = bounds.start + (bounds.end - bounds.start) / 2 + const viewportCenterOffset = (viewportTopInset + container.clientHeight) / 2 + return targetCenter - viewportCenterOffset } -export function revealElementInScrollContainer( +export function getCenteringScrollPadding( + container: HTMLElement, + bounds: SidebarRevealBounds, + topInset = 0 +): SidebarCenteringScrollPadding { + const desiredScrollTop = getScrollTopToRevealBounds(container, bounds, topInset) + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight) + return { + start: Math.ceil(Math.max(0, -desiredScrollTop)), + end: Math.ceil(Math.max(0, desiredScrollTop - maxScrollTop)) + } +} + +export function getElementCenteringScrollPadding( container: HTMLElement, element: Element, - behavior: ScrollBehavior -): boolean { + topInset = WORKTREE_SIDEBAR_REVEAL_TOP_INSET +): SidebarCenteringScrollPadding | null { + if (!container.contains(element)) { + return null + } + return getCenteringScrollPadding(container, getElementScrollBounds(container, element), topInset) +} + +export function revealElementInScrollContainer(container: HTMLElement, element: Element): boolean { if (!container.contains(element)) { return false } @@ -48,17 +69,8 @@ export function revealElementInScrollContainer( getElementScrollBounds(container, element), WORKTREE_SIDEBAR_REVEAL_TOP_INSET ) - if (nextScrollTop === null) { - return true - } - // Why: honor the user's reduced-motion preference by jumping instantly instead of - // animating a smooth scroll (also makes the reveal deterministic in headless - // environments that never tick the smooth-scroll animation). - const prefersReducedMotion = - typeof window !== 'undefined' && - window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true - const resolvedBehavior: ScrollBehavior = - behavior === 'smooth' && prefersReducedMotion ? 'auto' : behavior - container.scrollTo({ top: Math.max(0, nextScrollTop), behavior: resolvedBehavior }) + // Why: sidebar reveal is a focus handoff, so reposition immediately instead + // of making the user track an animated list. + container.scrollTop = Math.max(0, nextScrollTop) return true } diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts index 8cd870aec13..35686a4abee 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts @@ -153,9 +153,7 @@ describe('forkAgentSessionFromPane', () => { launchSource: 'terminal_context_menu' }) ) - expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', { - sidebarRevealBehavior: 'auto' - }) + expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork') expect(mockToast.success).toHaveBeenCalledWith( 'Top-level session fork opened in a new workspace' ) @@ -346,9 +344,7 @@ describe('forkAgentSessionFromPane', () => { undefined ) expect(mockLaunchAgentInNewTab).not.toHaveBeenCalled() - expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', { - sidebarRevealBehavior: 'auto' - }) + expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork') expect(mockWriteClipboardText).toHaveBeenCalledWith( expect.stringContaining('Assistant: here is the current plan') ) diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts index 6b36a638749..7f2d62b4782 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts @@ -266,7 +266,7 @@ export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Pro const forkWorktreeId = created.worktree.id if (!fork.agent) { - activateAndRevealWorktree(forkWorktreeId, { sidebarRevealBehavior: 'auto' }) + activateAndRevealWorktree(forkWorktreeId) return copyAgentSessionForkContext(fork) } await preflightForkAgentTrust({ @@ -287,7 +287,7 @@ export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Pro launchSource: 'terminal_context_menu', ...(launchPlatform ? { launchPlatform } : {}) }) - activateAndRevealWorktree(forkWorktreeId, { sidebarRevealBehavior: 'auto' }) + activateAndRevealWorktree(forkWorktreeId) if (!result) { return copyAgentSessionForkContext(fork) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index d1316b98678..4f4b6ea8b10 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -3687,7 +3687,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS startupPlan.launchToken = createBrowserUuid() } const activation = activateAndRevealWorktree(worktree.id, { - sidebarRevealBehavior: 'auto', setup: result.setup, defaultTabs: result.defaultTabs, issueCommand, diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index c4c51907ccf..3a7683073cc 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -4025,7 +4025,6 @@ describe('useIpcEvents CLI-created worktree activation', () => { expect(activateAndRevealWorktree).toHaveBeenCalledTimes(1) expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-new', { setup, - sidebarRevealBehavior: 'auto', notifyHostRuntime: false }) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index f918744e62c..5657d69a558 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -935,12 +935,10 @@ export function useIpcEvents(): void { // stream and is allowed through this helper separately. return } - const existedBeforeFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId)) // Why: fetch worktrees first so the activation helper can resolve // the CLI-created worktree via findWorktreeById — it arrived from // the main process and is not yet in the renderer state. await useAppStore.getState().fetchWorktrees(repoId) - const existsAfterFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId)) // Why: route through activateAndRevealWorktree so CLI-created // worktrees share the canonical activation path with UI-created // ones. This records the visit in the back/forward history stack @@ -950,7 +948,6 @@ export function useIpcEvents(): void { ...(setup ? { setup } : {}), ...(startup ? { startup } : {}), ...(defaultTabs ? { defaultTabs } : {}), - ...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {}), // Why: this activation already came from the host runtime event stream. // Echoing it back as worktree.activate can create a selection loop. notifyHostRuntime: false diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 61f49274edb..7d88276cc51 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -216,7 +216,6 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom !isTuiAgentEnabled(agentOverride, latestStore.settings?.disabledTuiAgents) ) { activateAndRevealWorktree(worktreeId, { - sidebarRevealBehavior: 'auto', setup: result.setup }) toast.error(unavailableAgentErrorMessage()) @@ -282,7 +281,6 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom })) const activation = activateAndRevealWorktree(worktreeId, { - sidebarRevealBehavior: 'auto', setup: result.setup, defaultTabs: result.defaultTabs, ...buildDirectWorkItemStartupOpts(effectiveAgent, startupPlan, launchSource) diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 174c1fab32d..184eb7dc7c8 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -299,10 +299,10 @@ describe('activateAndRevealWorktree created agent reopen', () => { revealWorktreeInSidebar }) - const result = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto' }) + const result = activateAndRevealWorktree(worktree.id) expect(result).toEqual({ primaryTabId: expect.any(String) }) - expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id, { behavior: 'auto' }) + expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id) }) it('asks the host runtime to activate the worktree in the paired web client', async () => { diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 1d94786a345..4f735de5c17 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -25,7 +25,6 @@ import { CLIENT_PLATFORM } from './new-workspace' import { tuiAgentToAgentKind } from './telemetry' import { agentKindToTuiAgent } from '../../../shared/agent-kind' import { useAppStore } from '@/store' -import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { activateWebRuntimeSessionWorktree, @@ -183,7 +182,6 @@ function ensureFolderWorkspaceInitialTerminal( export function activateAndRevealFolderWorkspace( folderWorkspaceId: string, opts?: { - sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] startup?: WorktreeStartupPayload runtimeEnvironmentId?: string | null } @@ -227,11 +225,7 @@ export function activateAndRevealFolderWorkspace( resumeSleepingAgentSessionsForWorktree(workspaceKey) const primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup) - if (opts?.sidebarRevealBehavior) { - state.revealWorktreeInSidebar(workspaceKey, { behavior: opts.sidebarRevealBehavior }) - } else { - state.revealWorktreeInSidebar(workspaceKey) - } + state.revealWorktreeInSidebar(workspaceKey) return { primaryTabId } } @@ -289,7 +283,6 @@ export function activateAndRevealWorktree( setup?: WorktreeSetupLaunch defaultTabs?: WorktreeDefaultTabsLaunch issueCommand?: IssueCommandLaunch - sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] notifyHostRuntime?: boolean revealInSidebar?: boolean } @@ -389,11 +382,7 @@ export function activateAndRevealWorktree( // 6. Reveal in sidebar if (opts?.revealInSidebar !== false) { - if (opts?.sidebarRevealBehavior) { - state.revealWorktreeInSidebar(worktreeId, { behavior: opts.sidebarRevealBehavior }) - } else { - state.revealWorktreeInSidebar(worktreeId) - } + state.revealWorktreeInSidebar(worktreeId) } if (opts?.notifyHostRuntime !== false) { diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 9af1dc61ebe..3e37be6d186 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -222,7 +222,6 @@ async function executeWorktreeCreation( let primaryTabId: string | null if (stillActive) { activation = activateAndRevealWorktree(worktree.id, { - sidebarRevealBehavior: 'auto', ...(result.setup ? { setup: result.setup } : {}), ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), ...(startupOpt ? { startup: startupOpt } : {}), diff --git a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts index a18a2733dcc..71e071f91f4 100644 --- a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts +++ b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts @@ -56,7 +56,6 @@ describe('repo slice skipped-onboarding folder startup', () => { 1, 'folder-1::/folder', { - sidebarRevealBehavior: 'auto', startup: { command: "codex '--dangerously-bypass-approvals-and-sandbox'", env: {}, @@ -77,7 +76,7 @@ describe('repo slice skipped-onboarding folder startup', () => { expect(worktreeActivation.activateAndRevealWorktree).toHaveBeenNthCalledWith( 2, 'folder-2::/folder', - { sidebarRevealBehavior: 'auto' } + undefined ) }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 6fc31768e28..4f535acbce6 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -2586,10 +2586,7 @@ export const createRepoSlice: StateCreator = (set, onboarding, hadProjectBeforeAdd ) - activateAndRevealWorktree(folderWorktree.id, { - sidebarRevealBehavior: 'auto', - ...(startup ? { startup } : {}) - }) + activateAndRevealWorktree(folderWorktree.id, startup ? { startup } : undefined) } return repo } catch (err) { diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 6b4e88cbc24..202adfe5e31 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -286,7 +286,6 @@ describe('createUISlice agent send target mode', () => { }) expect(store.getState().pendingRevealWorktree).toMatchObject({ worktreeId, - behavior: 'auto', highlight: true }) }) @@ -1098,14 +1097,12 @@ describe('createUISlice hydratePersistedUI', () => { const store = createUIStore() store.getState().revealWorktreeInSidebar('repo1::/feature', { - behavior: 'smooth', highlight: true, beginRename: true }) expect(store.getState().pendingRevealWorktree).toEqual({ worktreeId: 'repo1::/feature', - behavior: 'smooth', highlight: true, beginRename: true }) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 04748058e16..7b280b7b100 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -113,14 +113,12 @@ import { translate } from '@/i18n/i18n' export type PendingSidebarWorktreeReveal = { worktreeId: string - behavior: 'auto' | 'smooth' highlight?: boolean beginRename?: boolean } export type PendingSidebarRowReveal = { rowKey: string - behavior: 'auto' | 'smooth' highlight?: boolean } @@ -897,7 +895,6 @@ export type UISlice = { revealWorktreeInSidebar: ( worktreeId: string, options?: { - behavior?: PendingSidebarWorktreeReveal['behavior'] highlight?: boolean beginRename?: boolean } @@ -905,7 +902,6 @@ export type UISlice = { revealSidebarRow: ( rowKey: string, options?: { - behavior?: PendingSidebarRowReveal['behavior'] highlight?: boolean } ) => void @@ -989,7 +985,7 @@ export const createUISlice: StateCreator = (set, get) targets.some((target) => target.status === 'eligible') && (previousMode?.id !== args.id || previousMode.worktreeId !== args.worktreeId) ) { - get().revealWorktreeInSidebar(args.worktreeId, { behavior: 'auto', highlight: true }) + get().revealWorktreeInSidebar(args.worktreeId, { highlight: true }) } }, closeAgentSendPopoverTargetMode: (id, instanceId) => @@ -2205,7 +2201,6 @@ export const createUISlice: StateCreator = (set, get) set({ pendingRevealWorktree: { worktreeId, - behavior: options?.behavior ?? 'smooth', ...(options?.highlight ? { highlight: true } : {}), ...(options?.beginRename ? { beginRename: true } : {}) } @@ -2214,7 +2209,6 @@ export const createUISlice: StateCreator = (set, get) set({ pendingRevealSidebarRow: { rowKey, - behavior: options?.behavior ?? 'smooth', ...(options?.highlight === false ? {} : { highlight: true }) } }), diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 8929f57594c..8e0cb6e3a30 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -6412,7 +6412,7 @@ describe('setWorktreesPinnedAndReveal', () => { store.getState().setWorktreesPinnedAndReveal([wt.id], true) expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(true) - expect(reveal).toHaveBeenCalledWith(wt.id, { behavior: 'smooth', highlight: true }) + expect(reveal).toHaveBeenCalledWith(wt.id, { highlight: true }) }) it('reveals on unpin so the viewport follows the row back to its status group', () => { @@ -6427,7 +6427,7 @@ describe('setWorktreesPinnedAndReveal', () => { store.getState().setWorktreesPinnedAndReveal([wt.id], false) expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(false) - expect(reveal).toHaveBeenCalledWith(wt.id, { behavior: 'smooth', highlight: true }) + expect(reveal).toHaveBeenCalledWith(wt.id, { highlight: true }) }) it('skips a no-op toggle without requesting a reveal', () => { @@ -6492,7 +6492,7 @@ describe('setWorktreesPinnedAndReveal', () => { store.getState().setWorktreesPinnedAndReveal([alreadyPinned.id, first.id, second.id], true) expect(reveal).toHaveBeenCalledTimes(1) - expect(reveal).toHaveBeenCalledWith(first.id, { behavior: 'smooth', highlight: true }) + expect(reveal).toHaveBeenCalledWith(first.id, { highlight: true }) // Every targeted row is pinned, not just the revealed one, and the // already-pinned row is left untouched. expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(true) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index d6fb11a315b..9c350fd7652 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3959,7 +3959,7 @@ export const createWorktreeSlice: StateCreator // persistence is async), so the reveal below resolves against a render // where the shortcut row already exists. void get().updateWorktreesMeta(updates) - get().revealWorktreeInSidebar(revealWorktreeId, { behavior: 'smooth', highlight: true }) + get().revealWorktreeInSidebar(revealWorktreeId, { highlight: true }) }, markWorktreeUnread: (worktreeId) => { diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts index 19c51005cfe..a3bef0d095b 100644 --- a/tests/e2e/worktree-scroll-to-current.spec.ts +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -23,11 +23,9 @@ async function prepareSidebarForScrollTest(page: Page): Promise { test.describe('Reveal active workspace button', () => { test.beforeEach(async ({ orcaPage }) => { - // Why: headless Electron under xvfb never ticks a smooth-scroll animation, - // so the reveal's `scrollTo({ behavior: 'smooth' })` would never reach its - // target. Reduced-motion makes the reveal jump instantly (see - // worktree-sidebar-reveal.ts) so the geometry assertions are deterministic. - await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) + // Sidebar reveal now always jumps instantly (worktree-sidebar-reveal.ts + // assigns scrollTop directly), so no reduced-motion emulation is needed + // for the geometry assertions to be deterministic. await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) }) diff --git a/tests/e2e/worktree-switch-responsiveness.spec.ts b/tests/e2e/worktree-switch-responsiveness.spec.ts index 45a6e6e6b2f..11b4dea8c97 100644 --- a/tests/e2e/worktree-switch-responsiveness.spec.ts +++ b/tests/e2e/worktree-switch-responsiveness.spec.ts @@ -33,8 +33,8 @@ async function prepareSidebarForSwitchTest(page: Page): Promise<[string, string] if ((state.tabsByWorktree[second.id] ?? []).length === 0) { state.createTab(second.id, undefined, undefined, { pendingActivationSpawn: true }) } - state.revealWorktreeInSidebar(first.id, { behavior: 'auto' }) - state.revealWorktreeInSidebar(second.id, { behavior: 'auto' }) + state.revealWorktreeInSidebar(first.id) + state.revealWorktreeInSidebar(second.id) state.setActiveWorktree(first.id) return [first.id, second.id] }) From 44d5ed04393837d6d04f975abbf76426d0212376 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:54:22 -0700 Subject: [PATCH 02/17] 1.4.131 rc2 release prep (#8020) * Support WSL Codex settings promotion and harden config write-back - Enable settings promotion for WSL runtimes using per-distro baselines. - Create parent directories if missing to prevent promotion ENOENTs. - Keep restrictive permissions (0600) and follow symlinks on promote. - Respect CRLF line endings when inserting keys into CRLF config files. - Skip redundant baseline file writes when settings are unchanged. - Include the release scan report for the 1.4.131-rc2 prep. * Refactor sleeping agent wake flow and fetch rate limits via backend - Background-mount only targeted terminal tabs during passive wake to prevent spawning unnecessary PTYs for unvisited tabs. - Latch edge-triggered wake requests that arrive mid-hibernation and track active claims to prevent double-resuming a provider session. - Query the ChatGPT wham usage backend API directly with fetch for rate limits, avoiding launching Codex or WSL login shells. - Asynchronously probe and serialize WSL auth files with timeouts to prevent synchronous I/O from stalling Electron's main process. - Fix config promotion edge cases such as missing parent directories, dangling symlinks, and atomic write permission widening. * Support WSL dotfile-symlink write-back and lengthen redeem timeout - Preserve symlinked Codex config on WSL by writing through the existing file instead of atomic-rename, since \\wsl$ symlink metadata isn't reliably detected and rename would clobber the link. - Tighten new ~/.codex directory creation to 0700 (holds auth.json). - Give explicit reset-credit redemption a 30s backend timeout instead of the 10s background-poll default, since it's user-triggered. - Read sleeping-agent session state from the worktree's actual execution-host partition instead of always the local one, so the headless-wake check works correctly for SSH-hosted worktrees. - Isolate serve-sim watcher tests from the real $TMPDIR/serve-sim state file to avoid leaking unrelated events. --- docs/STYLEGUIDE.md | 2 +- .../app/h/[hostId]/session/[worktreeId].tsx | 12 +- .../mobile-session-startup-source.test.ts | 2 + .../worktree-activation-result.test.ts | 13 + .../worktree/worktree-activation-result.ts | 8 + prod-release-scan-1.4.131-rc2-output.md | 152 ++++++++++ .../runtime-home-service.test.ts | 112 +++++++- .../codex-accounts/runtime-home-service.ts | 60 +++- src/main/codex/codex-config-mirror.test.ts | 15 + src/main/codex/codex-config-mirror.ts | 50 ++-- .../codex/config-settings-promotion.test.ts | 154 +++++++++- src/main/codex/config-settings-promotion.ts | 139 +++++++-- .../codex/hook-service-wsl-runtime.test.ts | 1 + .../emulator/serve-sim-state-watcher.test.ts | 17 +- src/main/persistence.ts | 6 + .../auth-filesystem-operation.test.ts | 119 ++++++++ .../rate-limits/auth-filesystem-operation.ts | 175 ++++++++++++ .../rate-limits/codex-auth-presence.test.ts | 108 +++++-- src/main/rate-limits/codex-auth-presence.ts | 51 +++- .../rate-limits/codex-fetcher-backend.test.ts | 194 +++++++++++++ src/main/rate-limits/codex-fetcher.test.ts | 36 ++- src/main/rate-limits/codex-fetcher.ts | 214 ++++++++++++-- src/main/runtime/orca-runtime.test.ts | 108 ++++++- src/main/runtime/orca-runtime.ts | 37 ++- src/renderer/src/App.tsx | 16 +- src/renderer/src/app-startup-routing.test.ts | 4 +- src/renderer/src/components/Terminal.tsx | 150 +++++++--- .../TerminalPaneOverlayLayer.tsx | 65 +++-- .../terminal-pane/pty-connection.test.ts | 192 ++++++++++++- .../terminal-pane/pty-connection.ts | 158 +++++++++-- .../use-terminal-pane-lifecycle.ts | 15 +- ...background-terminal-worktree-mount.test.ts | 120 +++++++- .../background-terminal-worktree-mount.ts | 148 ++++++++++ src/renderer/src/constants/terminal.ts | 11 + src/renderer/src/hooks/useIpcEvents.test.ts | 4 +- src/renderer/src/hooks/useIpcEvents.ts | 32 +-- .../launch-agent-background-session.test.ts | 63 ++++- .../lib/launch-agent-background-session.ts | 265 +++++++++--------- ...-agent-session-suppress-navigation.test.ts | 53 ++++ .../src/lib/resume-sleeping-agent-session.ts | 125 +-------- .../src/lib/sleeping-agent-session-launch.ts | 128 +++++++++ ...wake-sleeping-agents-in-background.test.ts | 257 +++++++++++++++-- .../lib/wake-sleeping-agents-in-background.ts | 199 +++++++++++-- .../slices/agent-status-quit-capture.test.ts | 49 ++++ src/renderer/src/store/slices/agent-status.ts | 63 +++-- src/shared/wsl-login-shell-command.test.ts | 58 ++++ 46 files changed, 3393 insertions(+), 567 deletions(-) create mode 100644 mobile/src/worktree/worktree-activation-result.test.ts create mode 100644 mobile/src/worktree/worktree-activation-result.ts create mode 100644 prod-release-scan-1.4.131-rc2-output.md create mode 100644 src/main/rate-limits/auth-filesystem-operation.test.ts create mode 100644 src/main/rate-limits/auth-filesystem-operation.ts create mode 100644 src/main/rate-limits/codex-fetcher-backend.test.ts create mode 100644 src/renderer/src/lib/sleeping-agent-session-launch.ts diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index 538b7805d91..d795f74039a 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -225,7 +225,7 @@ Three scrollbar classes are defined globally in `main.css`: - **`.scrollbar-sleek`** — the default thin, neutral scrollbar for sidebars, lists, popovers. Pair with `.scrollbar-sleek-parent` on a hover-target ancestor if you want the thumb to fade in only on parent hover. - **`.scrollbar-editor`** — slightly heavier, used inside Monaco-adjacent surfaces. -- **`.worktree-sidebar-scrollbar`** — reserves the gutter but keeps the thumb invisible until the parent (`.scrollbar-sleek-parent`) is hovered. Used only in the worktree sidebar so the chrome stays still. +- **`.worktree-sidebar-scrollbar`** — no reserved gutter: paired with `overflow-y-auto`, the scrollbar (and its width) exists only while content actually overflows, so a short list stays flush with the fixed header controls and classic-scrollbar Windows shows no arrow buttons on empty lists. The thumb stays invisible until the parent (`.scrollbar-sleek-parent`) is hovered. Used only in the worktree sidebar. Apply one of these to overflow containers; don't write a fourth style. diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index da2aae83fce..2043f28e1a9 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -76,6 +76,7 @@ import { import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context' import { SessionDockColumn } from '../../../../src/session/SessionDockColumn' import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types' +import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result' import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation' import { triggerMediumImpact, @@ -2690,6 +2691,11 @@ export default function SessionScreen() { timers.push(setTimeout(fn, ms)) } void (async () => { + const reportActivationOutcome = (response: RpcSuccess | null): void => { + if (!disposed && response && headlessActivationNeedsHostRenderer(response.result)) { + showToast('Open Orca on the host to wake sleeping agents.', 3000) + } + } if (client && created !== '1') { // Why: mobile needs host-owned tabs hydrated for this route, but should // not pull other paired clients, especially desktop, into this worktree. @@ -2698,6 +2704,7 @@ export default function SessionScreen() { worktree: `id:${worktreeId}`, notifyClients: false }) + .then((response) => reportActivationOutcome(response.ok ? response : null)) .catch(() => null) } if (disposed) { @@ -2719,12 +2726,13 @@ export default function SessionScreen() { return } void (async () => { - await client + const activationResponse = await client .sendRequest('worktree.activate', { worktree: `id:${worktreeId}`, notifyClients: false }) .catch(() => null) + reportActivationOutcome(activationResponse?.ok ? activationResponse : null) if (disposed) { return } @@ -2740,7 +2748,7 @@ export default function SessionScreen() { clearTimeout(t) } } - }, [client, connState, created, fetchSessionTabs, fetchTerminals, worktreeId]) + }, [client, connState, created, fetchSessionTabs, fetchTerminals, showToast, worktreeId]) useEffect(() => { if (!client || connState !== 'connected') { diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index be295594f81..aca17cfb509 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -41,6 +41,8 @@ describe('mobile session startup', () => { expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan( startupEffect.indexOf('await fetchSessionTabs()') ) + expect(startupEffect).toContain('headlessActivationNeedsHostRenderer(response.result)') + expect(startupEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'") }) it('activates an already-selected pending terminal tab after hydration', () => { diff --git a/mobile/src/worktree/worktree-activation-result.test.ts b/mobile/src/worktree/worktree-activation-result.test.ts new file mode 100644 index 00000000000..11abc5e6970 --- /dev/null +++ b/mobile/src/worktree/worktree-activation-result.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { headlessActivationNeedsHostRenderer } from './worktree-activation-result' + +describe('headlessActivationNeedsHostRenderer', () => { + it('only reports the explicit sleeping-agent headless outcome', () => { + expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'unsupported-headless' })).toBe( + true + ) + expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'not-applicable' })).toBe(false) + expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'requested' })).toBe(false) + expect(headlessActivationNeedsHostRenderer(null)).toBe(false) + }) +}) diff --git a/mobile/src/worktree/worktree-activation-result.ts b/mobile/src/worktree/worktree-activation-result.ts new file mode 100644 index 00000000000..101fc2c6cd0 --- /dev/null +++ b/mobile/src/worktree/worktree-activation-result.ts @@ -0,0 +1,8 @@ +export function headlessActivationNeedsHostRenderer(result: unknown): boolean { + return Boolean( + result && + typeof result === 'object' && + 'sleepingAgentWake' in result && + result.sleepingAgentWake === 'unsupported-headless' + ) +} diff --git a/prod-release-scan-1.4.131-rc2-output.md b/prod-release-scan-1.4.131-rc2-output.md new file mode 100644 index 00000000000..4e5b0188ccd --- /dev/null +++ b/prod-release-scan-1.4.131-rc2-output.md @@ -0,0 +1,152 @@ +# Prod Release Scan — output.md + +**Verdict:** ready with accepted P1 risk +**Span:** `v1.4.130` (`31378023`) .. `v1.4.131-rc.2` (`6964bfb23`) +**Planned release / RC:** `1.4.131-rc.2` (user asked for 1.4.132-rc2; that tag does not exist — scanned confirmed substitute) +**Included PRs:** 8 matched (+ `#7567` referenced only in `#7658` title, not a separate PR); unmatched commits: 5 +**Batches reviewed:** 6 (agent/mobile, codex/speech/WSL, UI defaults, xplat/remoting, perf, security) +**Security model:** `codex --model gpt-5.6-sol -c model_reasoning_effort="xhigh"` (W6 + all workers) +**Orchestration:** 6 same-worktree tasks injected; `task-list`/`dispatch-show` verified + +--- + +## Issue table by PR / commit + +### Has issues + +| Ref | Title | Severity | Issue summary | +|-----|-------|----------|---------------| +| **#7906** | Wake slept agents when opening a worktree on mobile | **P1** (×3) | (1) Non-passive / manual-sleep wake on an **unmounted** worktree creates a tab with `activate:false` but never background-mounts → **no PTY / resume lost**. (2) Mounted manual-sleep can **double-resume** the same provider session (in-place wake + generic resume). (3) Edge-triggered wake can be **lost** if mobile opens during hibernation kill before `hibernatedWakePtyId` is armed. | +| **#7906** | (perf composition) | **P1** | Passive completed wake background-mounts the **entire** worktree → O(all saved tabs) PTY/xterm work, defeating unvisited-worktree mass-spawn guard. | +| **#7906** | (headless remoting) | **P1** | Wake is a no-op without a live renderer window; headless `orca serve --mobile-pairing` activates without waking slept agents. | +| **#7960** | Write in-Codex setting changes back to `~/.codex` | **P1** (×2–3) | (1) Missing `~/.codex/` parent → `ENOENT` on promote, then mirror **wipes** runtime setting (test pre-creates dir and misses this). (2) `writeFileAtomically` without `mode` widens **0600 → 0644** on promote (reproduced). (3) Host-only promotion path: **WSL Codex never promotes** (WSL prep skips `syncSystemConfigIntoManagedCodexHome`). | +| **#7658** | Recover WSL Codex usage RPC refresh | **P2** | Every WSL quota poll uses interactive login shell (`-ilc` + discovery chain); heavy rc cost on 15m poll / double on PTY fallback. | +| **#7977** | Disable experimental new worktree card style by default | **P2** | Fresh default is correctly off, but cohort **auto-enabled in v1.4.130** stays on via persisted `true` (no rollback migration). | +| **#7845** | Sidebar scrollbar only when overflow | **P2** (docs only) | Behavior OK; `docs/STYLEGUIDE.md` still says gutter is reserved — stale contract. | + +### No issues found (clean for this scan) + +| Ref | Title | Notes | +|-----|-------|-------| +| **#7860** | Separate pane identity from liveness (OMP tab flicker) | Identity vs liveness separation consistent across tab icon, PTY owner, web mirror; focused-pane owner re-owns Pi-compatible titles; no proven regression. | +| **#7932** | Chunk offline audio decoding (ONNX SIGTRAP) | 30s chunk bound + quiet split; worker-side decode; pure chunker tests pass; no malware/native dep delta. Residual: no packaged ONNX runtime on all platforms. | +| **#7933** | iOS native keyboard dictation in mobile terminal | Raw field echo; normalize only on send/mirror; no double-send proven. Mobile vitest not run (missing Expo deps in this worktree). | +| `775fa956c` | Remove mobile driver overlay preview | Delete-only HTML mock. | +| `8adfef4ff` | Update README downloads badge | `4.0m` → `4.1m` only. | +| `ff661a7ab` / `41edc493b` / `6964bfb23` | release: v1.4.131-rc.{0,1,2} | Version bumps in `package.json` only. | + +--- + +## Findings first + +### P0 — Release Blocking + +None. + +No malware, backdoor, supply-chain implant, lockfile/dependency implant, install-hook change, or updater/signing bypass in this span. + +### P1 — Releasable Only With Explicit Acceptance + +1. **#7906 Mobile wake — unmounted non-passive sleep never gets a PTY** + `wake-sleeping-agents-in-background.ts:43-56` only background-mounts for passive completed records; non-passive path creates inactive tab and clears the sleep record without a mounted `TerminalPane`. + *Evidence:* `wake-sleeping-agents-in-background.ts`, `resume-sleeping-agent-session.ts:93-118`, `Terminal.tsx:729-778`. + +2. **#7906 Mobile wake — double resume of same provider session** + Broadcast in-place wake then generic resume for non-passive mounted records before spawn clears the record. + *Evidence:* `wake-sleeping-agents-in-background.ts:37-56`, `pty-connection.ts:6247-6255`, `resume-sleeping-agent-session.ts:279-315`. + +3. **#7906 Mobile wake — race during hibernation kill** + Wake before `hibernatedWakePtyId` is armed is edge-triggered and not retried; passive path keeps record without launching. + *Evidence:* `pty-connection.ts:2090-2169`, `orca-runtime.ts` fire-and-forget notifier. + +4. **#7906 Perf — whole-worktree background mount** + One passive sleep record mounts every terminal tab in the worktree permanently. + *Evidence:* `wake-sleeping-agents-in-background.ts:46-54`, `background-terminal-worktree-visibility.ts`, `Terminal.tsx` mount model. + +5. **#7906 Headless mobile pairing — wake no-op** + No renderer ⇒ no wake; headless serve still returns activated. + *Evidence:* `orca-runtime.ts:12627-12633`, `orca-runtime.test.ts` “deliberate non-goal”, `mobile/scripts/start-emulator-pairing-runtime.mjs`. + +6. **#7960 Missing `~/.codex` directory loses first promoted setting** + Promote does not `mkdir` system home; `ENOENT` swallowed; mirror then clears runtime value. Test pre-creates `.codex` and misses the case. + *Evidence:* `config-settings-promotion.ts:191-192`, `fs-utils.ts:6-14`, `config-settings-promotion.test.ts:190-191`. Reproduced: missing parent → `ENOENT`. + +7. **#7960 Permission widening on promote (privacy)** + Atomic write without mode: `0600` → `0644` under umask `0022` (reproduced). Full user config becomes world-readable to co-tenants when parent dirs are traversable. + *Evidence:* `config-settings-promotion.ts:191-192`, `fs-utils.ts:11-14`. + +8. **#7960 WSL never gets promotion** + WSL launch/rate-limit prep returns after `syncWslRuntimeForCurrentSelection` and never calls host mirror/promotion. Feature is host-only. + *Evidence:* `runtime-home-service.ts:139-150,225-235`. + +### P2 — Nice To Have + +- **#7658** Interactive login shell on every WSL rate-limit poll (`buildWslLoginShellCommand` / `-ilc`). +- **#7960** Unchanged baseline still rewritten twice per quota cycle; CRLF insert can mix EOL on new keys. +- **#7977** Auto-enabled experimental card cohort not rolled back. +- **#7845** STYLEGUIDE scrollbar gutter text out of date. + +--- + +## Clean matrix + +| Area | Status | +|------|--------| +| Security / malware / supply-chain | **Clean of malice.** Lockfile unchanged; package scripts unchanged; version-only package.json. **P1 residual:** mode widen on `~/.codex/config.toml` promote. | +| Performance / resource use | **P1** mobile whole-worktree mount; **P2** baseline double-write + WSL interactive poll cost. STT chunking is an improvement. | +| macOS / Linux / Windows / WSL / PowerShell | **P1** WSL promotion gap; **P2** WSL interactive poll; CRLF insert. No new hard-coded Mac shortcuts. Real Win/WSL runtime not executed. | +| Mobile / paired web / Electron | **P1** mobile wake correctness/composition gaps; #7860 / #7933 clean by source audit. | +| Local / SSH / remote / VM / container | Wake path failures apply to local and SSH worktrees once host renderer is involved. Headless serve gap explicit. | +| Git providers / shared contracts | Not materially touched. | +| Release / package / update path | Version bumps only; no workflow/signing/native rebuild changes. | + +--- + +## Verification + +| Check | Result | +|-------|--------| +| `git diff --check v1.4.130..v1.4.131-rc.2` | Passed | +| Required security grep on non-lockfile span | 2 hits — both TOML `.exec` parsers in #7960; inspected, benign | +| `package.json` / lockfile | Version field only; `pnpm-lock.yaml` no diff | +| Mode repro (`writeFileSync` without mode) | `before 600` → `after 644` | +| Missing parent repro | `ENOENT` | +| Worker unit tests (various focused suites) | Reported green by workers (promotion, STT chunker, rate-limit, wake helpers, pane owner, etc.) | +| Mobile Expo vitest / device / real WSL / ONNX packaged | **Not run** in this environment | + +--- + +## Orchestration provenance + +| Worker | Task | Terminal | Report | +|--------|------|----------|--------| +| W1 agent/mobile | `task_20d8fd2460cc` | `term_40e66a33-…` | `reports/w1-agent-mobile.md` | +| W2 codex/speech/WSL | `task_6e280ce1760e` | `term_59f3ead8-…` | `reports/w2-codex-speech-wsl.md` | +| W3 UI/defaults | `task_7140cb2014c9` | `term_4e274a95-…` | `reports/w3-ui-defaults.md` | +| W4 xplat/remoting | `task_424816ffe823` | `term_03c7aac8-…` | `reports/w4-xplat-remoting.md` | +| W5 perf | `task_1c5ac42d7659` | `term_24778551-…` | `reports/w5-perf.md` | +| W6 security | `task_bf5de6f4913a` | `term_ae19a68d-…` | `reports/w6-security.md` | + +All workers: `codex --model gpt-5.6-sol -c model_reasoning_effort="xhigh"`, same worktree, `--inject` dispatch. + +--- + +## Residual risk + +- No runtime on physical iOS/Android, Windows+WSL2, or packaged sherpa/ONNX for all platforms. +- Lockfile/binary deltas fully inspected: **none**. Install hooks unchanged. +- #7906 integration of IPC wake × mounted/unmounted panes × async PTY exit is under-tested relative to risk. +- #7960 Windows ACL inheritance and concurrent external `~/.codex` edits not runtime-tested; POSIX mode widen is proven. + +--- + +## Bottom line + +**No P0.** Multiple **P1**s cluster on **#7906 (mobile wake)** and **#7960 (Codex settings promotion)**. Ship only if product explicitly accepts those gaps, or fix before stable: + +1. Always background-mount (or otherwise attach) when creating a navigation-suppressed resume tab. +2. Deduplicate in-place hibernation wake vs generic resume for the same claim key. +3. `mkdir` system `~/.codex` before promote; pass `mode: 0o600` (or preserve existing mode) into `writeFileAtomically`. +4. Decide WSL + headless pairing product stance (implement promotion/wake or document as unsupported). + +Clean shippable areas for this span: **#7860**, **#7932**, **#7933**, release version commits, README badge, preview delete; **#7845** functionally clean with docs P2. diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 7a1facf1420..9447e81ef75 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -533,6 +533,74 @@ describe('CodexRuntimeHomeService', () => { } }) + it('promotes WSL in-Codex setting changes on the next Codex launch', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.doMock('../codex/wsl-codex-session-bridge', () => ({ + startWslCodexSessionBridgeInBackground: vi.fn(() => Promise.resolve()) + })) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + const wslSystemConfigPath = join(wslHome, '.codex', 'config.toml') + mkdirSync(join(wslHome, '.codex'), { recursive: true }) + writeFileSync(wslSystemConfigPath, 'model = "gpt-5"\n', 'utf-8') + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + // First launch seeds the runtime config and records the per-distro baseline. + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json') + expect(existsSync(baselinePath)).toBe(true) + + // A direct WSL Codex edit wins and is mirrored into Orca's runtime before + // the baseline advances, so later in-Orca changes remain promotable. + const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') + writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8') + service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') + expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""') + + // Codex now persists a /model change inside Orca's reconciled runtime. + writeFileSync( + runtimeConfigPath, + readFileSync(runtimeConfigPath, 'utf-8').replace('model = "outside-edit"', 'model = "o4"'), + 'utf-8' + ) + + service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n') + // Baseline advances so the promoted value is not re-promoted forever. + expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""') + } finally { + vi.doUnmock('../codex/wsl-codex-session-bridge') + vi.doUnmock('../wsl') + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + it('bridges WSL history from a configured per-distro source-home override', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -1892,7 +1960,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('uses the stable WSL runtime home for WSL system-default rate-limit fetches', async () => { + it('reads WSL system-default rate limits from the live system home without materializing', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -1910,10 +1978,21 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + const syncWslRuntime = vi.spyOn( + service as unknown as { + syncWslRuntimeForCurrentSelection: (target: { + runtime: 'wsl' + wslDistro?: string | null + }) => string | null + }, + 'syncWslRuntimeForCurrentSelection' ) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const expectedHome = join(wslHome, '.codex') + + expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome) + expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome) + expect(syncWslRuntime).not.toHaveBeenCalled() } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -2041,20 +2120,23 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + systemCodexHomePath ) expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemDefaultAuth) + const externallyRefreshedAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'system-refreshed', + 3_000 + ) + writeFileSync(join(systemCodexHomePath, 'auth.json'), externallyRefreshedAuth, 'utf-8') + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + systemCodexHomePath + ) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe( + externallyRefreshedAuth + ) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 67120f15e53..754a0f1113a 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -139,9 +139,9 @@ export class CodexRuntimeHomeService { prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const runtimeHomePath = - this.syncWslRuntimeForCurrentSelection(wslTarget) ?? - this.getWslSystemCodexHomePath(wslTarget) + const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) + this.syncWslConfigSettingsForLaunch(wslTarget, syncedRuntimeHomePath) + const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) return runtimeHomePath } @@ -222,13 +222,33 @@ export class CodexRuntimeHomeService { return home ? this.joinWslPath(home, '.codex') : null } + // Why: WSL needs the same promote-then-mirror transaction as host. It keeps + // in-Orca changes while reconciling a newer external ~/.codex edit before + // advancing the per-distro baseline. Only runs on a materialized runtime. + private syncWslConfigSettingsForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void { + if (!runtimeHomePath) { + return + } + const distro = + parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return + } + const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + if (!systemHomePath || systemHomePath === runtimeHomePath) { + return + } + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + } + prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - return ( - this.syncWslRuntimeForCurrentSelection(wslTarget) ?? - this.getWslSystemCodexHomePath(wslTarget) - ) + const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) + return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() @@ -503,6 +523,32 @@ export class CodexRuntimeHomeService { return parseWslUncPath(account.managedHomePath) ? account.managedHomePath : null } + private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { + const distro = target.wslDistro?.trim() + if (distro) { + const settings = this.store.getSettings() + const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target) + if (selectedAccountId === null) { + // Why: the system-default account changes outside Orca (login, logout, + // token refresh). Read its real home directly so a cached runtime copy + // cannot stay stale; filesystem probing in the fetcher is asynchronous. + return this.getWslSystemCodexHomePath(target) + } + const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) + if ( + cachedRuntimeHomePath && + this.lastSyncedWslAccountIdByDistro.has(distro) && + this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId + ) { + // Why: RateLimitService resolves provenance twice per poll. Account + // changes sync explicitly, so repeated resolution must stay path-only + // instead of blocking main on UNC reads and a wsl.exe migration probe. + return cachedRuntimeHomePath + } + } + return this.syncWslRuntimeForCurrentSelection(target) + } + private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { return null diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 416ce9af78b..885e2030548 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -25,6 +25,7 @@ vi.mock('node:os', async () => { import { prepareSystemConfigForFreshRuntimeMirror, + resolveCodexConfigMirrorSourceDirectory, syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' @@ -402,6 +403,20 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { }) describe('prepareSystemConfigForFreshRuntimeMirror', () => { + it('uses the Linux-side directory for WSL UNC source homes', () => { + const sourceDir = resolveCodexConfigMirrorSourceDirectory( + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' + ) + + expect(sourceDir).toBe('/home/alice/.codex') + expect( + prepareSystemConfigForFreshRuntimeMirror( + 'model_instructions_file = "instructions.md"\n', + sourceDir + ) + ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") + }) + it('rewrites relative paths against a Linux-side home and strips hook trust', () => { const prepared = prepareSystemConfigForFreshRuntimeMirror( [ diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 605f2cd5f24..422b0e79280 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -3,9 +3,11 @@ import { dirname, join } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite' +import { parseWslUncPath } from '../../shared/wsl-paths' import { promoteCodexRuntimeSettingsToSystem, - snapshotCodexRuntimeSettingsBaseline + snapshotCodexRuntimeSettingsBaseline, + type CodexSettingsPromotionHomes } from './config-settings-promotion' import { createTomlLineScanState, @@ -14,33 +16,37 @@ import { updateTomlLineScanState } from './config-toml-line-scan' -function getRuntimeCodexConfigTomlPath(): string { - return join(getOrcaManagedCodexHomePath(), 'config.toml') -} - -function getSystemCodexConfigTomlPath(): string { - return join(getSystemCodexHomePath(), 'config.toml') -} - -export function syncSystemConfigIntoManagedCodexHome(): void { +export function syncSystemConfigIntoManagedCodexHome( + homes: CodexSettingsPromotionHomes = { + runtimeHomePath: getOrcaManagedCodexHomePath(), + systemHomePath: getSystemCodexHomePath() + } +): void { // Why: the mirror overwrites runtime settings from ~/.codex, so changes the // user made inside Orca-launched Codex (/model, /approvals) must be written // back to ~/.codex first or this very pass silently reverts them. - promoteCodexRuntimeSettingsToSystem() + if (!promoteCodexRuntimeSettingsToSystem(homes)) { + // Why: mirroring after a failed write-back would erase the runtime change; + // leave both runtime and its old baseline intact so the next launch retries. + return + } try { - syncSystemConfigIntoManagedCodexHomeUnsafe() + syncSystemConfigIntoManagedCodexHomeUnsafe(homes) } catch (error) { console.warn('[codex-config] Failed to mirror system Codex config:', error) return } // Why: the baseline advances only after a successful mirror; recording an // unpromoted runtime change as Orca-written would strand it forever. - snapshotCodexRuntimeSettingsBaseline() + snapshotCodexRuntimeSettingsBaseline(homes.runtimeHomePath) } -function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { - const systemConfigPath = getSystemCodexConfigTomlPath() - const runtimeConfigPath = getRuntimeCodexConfigTomlPath() +function syncSystemConfigIntoManagedCodexHomeUnsafe({ + runtimeHomePath, + systemHomePath +}: CodexSettingsPromotionHomes): void { + const systemConfigPath = join(systemHomePath, 'config.toml') + const runtimeConfigPath = join(runtimeHomePath, 'config.toml') const systemConfigExists = existsSync(systemConfigPath) const runtimeConfigExists = existsSync(runtimeConfigPath) if (!systemConfigExists && !runtimeConfigExists) { @@ -48,18 +54,16 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { } const rawSystemConfig = systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '' + const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath) if (!runtimeConfigExists) { writeFileAtomically( runtimeConfigPath, - prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, dirname(systemConfigPath)) + prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir) ) return } - const systemConfig = prepareSystemConfigForRuntimeMirror( - rawSystemConfig, - dirname(systemConfigPath) - ) + const systemConfig = prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir) const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig) if (mergedConfig !== runtimeConfig) { @@ -67,6 +71,10 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { } } +export function resolveCodexConfigMirrorSourceDirectory(systemHomePath: string): string { + return parseWslUncPath(systemHomePath)?.linuxPath ?? dirname(join(systemHomePath, 'config.toml')) +} + function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: string): string { return rewriteRelativePathConfigValues( normalizeDeprecatedCodexHookFeatureFlag(config), diff --git a/src/main/codex/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts index f81866d8751..f6ceabc362f 100644 --- a/src/main/codex/config-settings-promotion.test.ts +++ b/src/main/codex/config-settings-promotion.test.ts @@ -1,11 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' import { homedir, tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' +import type * as CodexFsUtils from '../codex-accounts/fs-utils' -const { homedirMock } = vi.hoisted(() => ({ - homedirMock: vi.fn<() => string>() +const { homedirMock, promotionTestState } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>(), + promotionTestState: { failAtomicWrite: false } })) vi.mock('node:os', async (importOriginal) => { @@ -16,6 +30,19 @@ vi.mock('node:os', async (importOriginal) => { } }) +vi.mock('../codex-accounts/fs-utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileAtomically: (...args: Parameters) => { + if (promotionTestState.failAtomicWrite) { + throw new Error('injected atomic write failure') + } + return actual.writeFileAtomically(...args) + } + } +}) + import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' import { upsertTopLevelSettingsInContent } from './config-settings-promotion' @@ -29,6 +56,7 @@ beforeEach(() => { previousUserDataPath = process.env.ORCA_USER_DATA_PATH process.env.ORCA_USER_DATA_PATH = userDataDir homedirMock.mockReturnValue(tmpHome) + promotionTestState.failAtomicWrite = false // Why: promotion writes into homedir()/.codex — if the mock ever fails to // intercept, these tests would rewrite the developer's real Codex config. if (homedir() !== tmpHome) { @@ -188,7 +216,9 @@ describe('codex settings write-back promotion', () => { }) it('creates ~/.codex/config.toml when a user without one changes a setting', () => { - mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + // Why: no mkdir of ~/.codex here — a genuinely fresh host has neither the + // config nor its directory, and promotion must create both. + expect(existsSync(join(tmpHome, '.codex'))).toBe(false) syncSystemConfigIntoManagedCodexHome() expect(existsSync(baselinePath())).toBe(true) @@ -266,6 +296,110 @@ describe('codex settings write-back promotion', () => { expect(readSystemConfig()).toBe('model = "o4"\n') }) + + it.skipIf(process.platform === 'win32')( + 'preserves a restrictive mode on the existing ~/.codex/config.toml', + () => { + writeSystemConfig('model = "gpt-5"\n') + chmodSync(systemConfigPath(), 0o600) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "o4"\n') + expect(statSync(systemConfigPath()).mode & 0o777).toBe(0o600) + } + ) + + it.skipIf(process.platform === 'win32')( + 'creates a new ~/.codex/config.toml with owner-only permissions', + () => { + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(statSync(systemConfigPath()).mode & 0o777).toBe(0o600) + // The created ~/.codex itself is owner-only — it will also hold + // auth.json once the user signs in. + expect(statSync(join(tmpHome, '.codex')).mode & 0o777).toBe(0o700) + } + ) + + it.skipIf(process.platform === 'win32')( + 'writes through a symlinked config.toml without replacing the link', + () => { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + const realConfigPath = join(tmpHome, 'dotfiles-config.toml') + writeFileSync(realConfigPath, 'model = "gpt-5"\n', 'utf-8') + symlinkSync(realConfigPath, systemConfigPath()) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(lstatSync(systemConfigPath()).isSymbolicLink()).toBe(true) + expect(readFileSync(realConfigPath, 'utf-8')).toBe('model = "o4"\n') + } + ) + + it.skipIf(process.platform === 'win32')( + 'preserves a dangling config.toml symlink and creates its target', + () => { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + const realConfigPath = join(tmpHome, 'dotfiles', 'config.toml') + symlinkSync(realConfigPath, systemConfigPath()) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(lstatSync(systemConfigPath()).isSymbolicLink()).toBe(true) + expect(readFileSync(realConfigPath, 'utf-8')).toBe('model = "o4"\n') + } + ) + + it('inserts a missing key into a CRLF config with CRLF endings', () => { + writeSystemConfig('model = "gpt-5"\r\n\r\n[features]\r\nhooks = true\r\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('approval_policy', '"never"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\r\napproval_policy = "never"\r\n\r\n[features]\r\nhooks = true\r\n' + ) + }) + + it('does not rewrite an unchanged settings baseline', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + const past = new Date(Date.now() - 120_000) + utimesSync(baselinePath(), past, past) + syncSystemConfigIntoManagedCodexHome() + + expect(statSync(baselinePath()).mtimeMs).toBeLessThan(Date.now() - 60_000) + }) + + it('keeps the old baseline and retries after a transient promotion failure', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + const baselineBeforeFailure = readFileSync(baselinePath(), 'utf-8') + simulateCodexSettingWrite('model', '"o4"') + + promotionTestState.failAtomicWrite = true + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(readRuntimeConfig()).toBe('model = "o4"\n') + expect(readFileSync(baselinePath(), 'utf-8')).toBe(baselineBeforeFailure) + + promotionTestState.failAtomicWrite = false + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "o4"\n') + }) }) describe('upsertTopLevelSettingsInContent', () => { @@ -293,4 +427,16 @@ describe('upsertTopLevelSettingsInContent', () => { ) ).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n') }) + + it('inserts with CRLF endings into CRLF content', () => { + expect( + upsertTopLevelSettingsInContent('[features]\r\nhooks = true\r\n', new Map([['model', '"x"']])) + ).toBe('model = "x"\r\n\r\n[features]\r\nhooks = true\r\n') + }) + + it('appends with CRLF to a CRLF preamble-only file', () => { + expect( + upsertTopLevelSettingsInContent('approval_policy = "never"\r\n', new Map([['model', '"x"']])) + ).toBe('approval_policy = "never"\r\nmodel = "x"\r\n') + }) }) diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 934d57ed39e..7e8552bb9b6 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -1,6 +1,16 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + realpathSync, + statSync, + writeFileSync +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { parseWslUncPath } from '../../shared/wsl-paths' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { createTomlLineScanState, @@ -103,9 +113,10 @@ function readTopLevelSettingValues(configPath: string): Map() + while (!visited.has(currentPath)) { + visited.add(currentPath) + try { + if (!lstatSync(currentPath).isSymbolicLink()) { + return currentPath + } + currentPath = resolve(dirname(currentPath), readlinkSync(currentPath)) + } catch { + return currentPath + } + } + // Why: replacing any link in a cycle would destroy dotfile-manager state; + // abort promotion and leave the runtime/baseline intact for manual repair. + throw new Error(`Codex config symlink cycle at ${linkPath}`) } export function upsertTopLevelSettingsInContent( @@ -215,16 +310,17 @@ export function upsertTopLevelSettingsInContent( state = updateTomlLineScanState(state, line) } + // Why: CRLF configs keep a trailing \r after the split; new lines must use + // the file's existing endings or a Windows-owned config becomes mixed-EOL. + const usesCrlf = content.includes('\r\n') const insertions: string[] = [] for (const [key, raw] of updates) { const existingIndex = keyLineIndexes.get(key) const rendered = `${key} = ${raw}` if (existingIndex !== undefined) { - // Why: CRLF configs keep a trailing \r after the split; preserve it so - // the rewritten line matches the file's existing endings. lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered } else { - insertions.push(rendered) + insertions.push(usesCrlf ? `${rendered}\r` : rendered) } } if (insertions.length > 0) { @@ -233,10 +329,13 @@ export function upsertTopLevelSettingsInContent( insertAt -= 1 } if (insertAt === preambleEnd && preambleEnd < lines.length) { - insertions.push('') + insertions.push(usesCrlf ? '\r' : '') } lines.splice(insertAt, 0, ...insertions) } const result = lines.join('\n') - return result.endsWith('\n') || result.length === 0 ? result : `${result}\n` + if (result.endsWith('\n') || result.length === 0) { + return result + } + return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}` } diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 2bbf2bdd20d..20accbb5859 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -213,6 +213,7 @@ describe('Codex WSL runtime hook install', () => { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}`, WSL_DISTRO_NAME: 'Ubuntu', + ORCA_AGENT_HOOK_ENDPOINT: '', ORCA_AGENT_HOOK_PORT: '43210', ORCA_AGENT_HOOK_TOKEN: 'token', ORCA_PANE_KEY: 'pane-1', diff --git a/src/main/emulator/serve-sim-state-watcher.test.ts b/src/main/emulator/serve-sim-state-watcher.test.ts index 006531dff20..bf9d7a61fa1 100644 --- a/src/main/emulator/serve-sim-state-watcher.test.ts +++ b/src/main/emulator/serve-sim-state-watcher.test.ts @@ -6,6 +6,17 @@ import { ServeSimStateWatcher, type ServeSimStateDetectedEvent } from './serve-s const TEST_UDID = '11111111-2222-3333-4444-555555555555' +let isolatedStateDirSeq = 0 +// Why: the default state dir is the real $TMPDIR/serve-sim — a leftover state +// file from an actual simulator session would leak extra events into tests +// that only exercise PTY ingestion. Point at a dir that never exists. +function createIsolatedWatcher(): ServeSimStateWatcher { + isolatedStateDirSeq += 1 + return new ServeSimStateWatcher({ + stateDir: join(tmpdir(), `orca-serve-sim-test-none-${process.pid}-${isolatedStateDirSeq}`) + }) +} + async function waitForEvent( events: ServeSimStateDetectedEvent[], predicate: (event: ServeSimStateDetectedEvent) => boolean @@ -68,7 +79,7 @@ describe('ServeSimStateWatcher', () => { }) it('suppresses Orca-managed sessions only while they are marked managed', async () => { - const watcher = new ServeSimStateWatcher() + const watcher = createIsolatedWatcher() const events: ServeSimStateDetectedEvent[] = [] const payload = JSON.stringify({ device: TEST_UDID, @@ -95,7 +106,7 @@ describe('ServeSimStateWatcher', () => { }) it('prunes worktree-scoped dedupe keys on forget so a re-bound worktree re-emits', () => { - const watcher = new ServeSimStateWatcher() + const watcher = createIsolatedWatcher() const events: ServeSimStateDetectedEvent[] = [] const payload = JSON.stringify({ device: TEST_UDID, @@ -122,7 +133,7 @@ describe('ServeSimStateWatcher', () => { }) it('dedupes one helper without hiding a later helper for the same simulator', () => { - const watcher = new ServeSimStateWatcher() + const watcher = createIsolatedWatcher() const events: ServeSimStateDetectedEvent[] = [] const firstPayload = JSON.stringify({ device: TEST_UDID, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0af7920b1a2..5ef81f071bd 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3094,6 +3094,12 @@ export class Store { ), settings: { ...defaults.settings, + // Why (#7977): a persisted experimentalNewWorktreeCardStyle:true is + // kept even though the default is now false. The v1.4.130 open- + // onboarding auto-default wrote the same plain boolean as a real + // opt-in, so a rollback migration would also revert genuine opt-ins; + // product intent was only to change the default, and the setting + // stays user-toggleable. ...stripLegacyTerminalScrollbackBytes(parsed.settings), // Why: v1.3.42 renamed the cosmetic sidekick setting to pet. Carry // the old persisted flag forward once so enabled users don't lose it. diff --git a/src/main/rate-limits/auth-filesystem-operation.test.ts b/src/main/rate-limits/auth-filesystem-operation.test.ts new file mode 100644 index 00000000000..92099fa2dcc --- /dev/null +++ b/src/main/rate-limits/auth-filesystem-operation.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAuthFilesystemOperation } from './auth-filesystem-operation' + +describe('createAuthFilesystemOperation', () => { + it('serializes WSL aliases by distro and drops an abandoned queued read', async () => { + let resolveFirst!: (value: string) => void + const firstRaw = vi.fn( + () => + new Promise((resolve) => { + resolveFirst = resolve + }) + ) + const secondRaw = vi.fn(async () => 'second') + const first = createAuthFilesystemOperation( + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json', + firstRaw + ) + const second = createAuthFilesystemOperation( + '\\\\WSL$\\ubuntu\\home\\alice\\managed\\auth.json', + secondRaw + ) + const firstController = new AbortController() + const secondController = new AbortController() + + const firstWait = first.wait(firstController.signal) + const secondWait = second.wait(secondController.signal) + await Promise.resolve() + await Promise.resolve() + expect(firstRaw).toHaveBeenCalledOnce() + expect(secondRaw).not.toHaveBeenCalled() + + const queuedAbort = new Error('queued caller expired') + secondController.abort(queuedAbort) + await expect(secondWait).rejects.toBe(queuedAbort) + + resolveFirst('first') + await expect(firstWait).resolves.toBe('first') + await expect(second.result).rejects.toBe(queuedAbort) + expect(secondRaw).not.toHaveBeenCalled() + }) + + it('allows different WSL distros to probe concurrently', async () => { + const ubuntuRaw = vi.fn(async () => 'ubuntu') + const debianRaw = vi.fn(async () => 'debian') + const ubuntu = createAuthFilesystemOperation( + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json', + ubuntuRaw + ) + const debian = createAuthFilesystemOperation( + '\\\\wsl.localhost\\Debian\\home\\alice\\.codex\\auth.json', + debianRaw + ) + const controller = new AbortController() + + await expect( + Promise.all([ubuntu.wait(controller.signal), debian.wait(controller.signal)]) + ).resolves.toEqual(['ubuntu', 'debian']) + expect(ubuntuRaw).toHaveBeenCalledOnce() + expect(debianRaw).toHaveBeenCalledOnce() + }) + + it('does not start an operation for an already-aborted waiter', async () => { + const raw = vi.fn(async () => 'unexpected') + const operation = createAuthFilesystemOperation( + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json', + raw + ) + const controller = new AbortController() + const abortError = new Error('already expired') + controller.abort(abortError) + + await expect(operation.wait(controller.signal)).rejects.toBe(abortError) + await expect(operation.result).rejects.toBe(abortError) + expect(raw).not.toHaveBeenCalled() + }) + + it('caps cross-distro operations and never starts an expired queued probe', async () => { + let resolveUbuntu!: (value: string) => void + let resolveDebian!: (value: string) => void + const ubuntuRaw = vi.fn( + () => + new Promise((resolve) => { + resolveUbuntu = resolve + }) + ) + const debianRaw = vi.fn( + () => + new Promise((resolve) => { + resolveDebian = resolve + }) + ) + const fedoraRaw = vi.fn(async () => 'fedora') + const ubuntu = createAuthFilesystemOperation('\\\\wsl$\\Ubuntu\\a\\auth.json', ubuntuRaw) + const debian = createAuthFilesystemOperation('\\\\wsl$\\Debian\\b\\auth.json', debianRaw) + const fedora = createAuthFilesystemOperation('\\\\wsl$\\Fedora\\c\\auth.json', fedoraRaw) + const ubuntuController = new AbortController() + const debianController = new AbortController() + const fedoraController = new AbortController() + + const ubuntuWait = ubuntu.wait(ubuntuController.signal) + const debianWait = debian.wait(debianController.signal) + const fedoraWait = fedora.wait(fedoraController.signal) + await Promise.resolve() + await Promise.resolve() + expect(ubuntuRaw).toHaveBeenCalledOnce() + expect(debianRaw).toHaveBeenCalledOnce() + expect(fedoraRaw).not.toHaveBeenCalled() + + const queuedAbort = new Error('global queue expired') + fedoraController.abort(queuedAbort) + await expect(fedoraWait).rejects.toBe(queuedAbort) + await expect(fedora.result).rejects.toBe(queuedAbort) + + resolveUbuntu('ubuntu') + resolveDebian('debian') + await expect(Promise.all([ubuntuWait, debianWait])).resolves.toEqual(['ubuntu', 'debian']) + expect(fedoraRaw).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/rate-limits/auth-filesystem-operation.ts b/src/main/rate-limits/auth-filesystem-operation.ts new file mode 100644 index 00000000000..228234e92c0 --- /dev/null +++ b/src/main/rate-limits/auth-filesystem-operation.ts @@ -0,0 +1,175 @@ +import { parseWslUncPath } from '../../shared/wsl-paths' + +const MAX_CONCURRENT_WSL_AUTH_OPERATIONS = 2 +const activeWslOperationDistros = new Set() +const queuedWslOperations: QueuedWslOperation[] = [] +let activeWslOperationCount = 0 + +type QueuedWslOperation = { + distroKey: string + neededSignal: AbortSignal + operation: () => Promise + resolve: (value: T) => void + reject: (error: unknown) => void + onAbort: () => void + state: 'queued' | 'running' | 'settled' +} + +function getAbortReason(signal: AbortSignal): unknown { + return signal.reason ?? new Error('Auth filesystem operation aborted') +} + +function finishWslOperation(task: QueuedWslOperation, settle: () => void): void { + if (task.state !== 'running') { + return + } + task.state = 'settled' + activeWslOperationCount -= 1 + activeWslOperationDistros.delete(task.distroKey) + settle() + pumpWslOperations() +} + +function pumpWslOperations(): void { + while (activeWslOperationCount < MAX_CONCURRENT_WSL_AUTH_OPERATIONS) { + const nextIndex = queuedWslOperations.findIndex( + (task) => !activeWslOperationDistros.has(task.distroKey) + ) + if (nextIndex === -1) { + return + } + const task = queuedWslOperations.splice(nextIndex, 1)[0] + if (!task || task.state !== 'queued') { + continue + } + task.neededSignal.removeEventListener('abort', task.onAbort) + if (task.neededSignal.aborted) { + task.state = 'settled' + task.reject(getAbortReason(task.neededSignal)) + continue + } + task.state = 'running' + activeWslOperationCount += 1 + activeWslOperationDistros.add(task.distroKey) + void Promise.resolve() + .then(() => { + if (task.neededSignal.aborted) { + throw getAbortReason(task.neededSignal) + } + return task.operation() + }) + .then( + (value) => finishWslOperation(task, () => task.resolve(value)), + (error: unknown) => finishWslOperation(task, () => task.reject(error)) + ) + } +} + +function scheduleWslAuthFilesystemOperation( + distroKey: string, + neededSignal: AbortSignal, + operation: () => Promise +): Promise { + return new Promise((resolve, reject) => { + const task: QueuedWslOperation = { + distroKey, + neededSignal, + operation, + resolve, + reject, + state: 'queued', + onAbort: () => { + if (task.state !== 'queued') { + return + } + task.state = 'settled' + const index = queuedWslOperations.indexOf(task as QueuedWslOperation) + if (index !== -1) { + queuedWslOperations.splice(index, 1) + } + reject(getAbortReason(neededSignal)) + pumpWslOperations() + } + } + neededSignal.addEventListener('abort', task.onAbort, { once: true }) + queuedWslOperations.push(task as QueuedWslOperation) + queueMicrotask(pumpWslOperations) + }) +} + +function scheduleAuthFilesystemOperation( + authPath: string, + neededSignal: AbortSignal, + operation: () => Promise +): Promise { + const wslInfo = parseWslUncPath(authPath) + if (!wslInfo) { + return Promise.resolve().then(() => { + if (neededSignal.aborted) { + throw getAbortReason(neededSignal) + } + return operation() + }) + } + // Why: a few disconnected distros must not occupy libuv's entire default + // filesystem pool. Distro serialization also folds wsl$/wsl.localhost and + // case aliases without forcing healthy local auth reads through the queue. + return scheduleWslAuthFilesystemOperation( + wslInfo.distro.trim().toLowerCase(), + neededSignal, + operation + ) +} + +export type SharedAuthFilesystemOperation = { + result: Promise + wait: (signal: AbortSignal) => Promise +} + +/** + * Shares one raw operation with all callers for an auth path. WSL paths also + * serialize by normalized distro because one stuck UNC request per account can + * otherwise exhaust libuv's filesystem threadpool. + */ +export function createAuthFilesystemOperation( + authPath: string, + operation: () => Promise +): SharedAuthFilesystemOperation { + const neededController = new AbortController() + const waiters = new Set() + let settled = false + const result = scheduleAuthFilesystemOperation(authPath, neededController.signal, operation) + const markSettled = (): void => { + settled = true + } + void result.then(markSettled, markSettled) + + return { + result, + wait(signal) { + if (signal.aborted) { + if (!settled && waiters.size === 0) { + neededController.abort(getAbortReason(signal)) + } + return Promise.reject(getAbortReason(signal)) + } + + const waiter = Symbol('auth-filesystem-waiter') + waiters.add(waiter) + let onAbort: (() => void) | null = null + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(getAbortReason(signal)) + signal.addEventListener('abort', onAbort, { once: true }) + }) + return Promise.race([result, aborted]).finally(() => { + if (onAbort) { + signal.removeEventListener('abort', onAbort) + } + waiters.delete(waiter) + if (!settled && waiters.size === 0) { + neededController.abort(getAbortReason(signal)) + } + }) + } + } +} diff --git a/src/main/rate-limits/codex-auth-presence.test.ts b/src/main/rate-limits/codex-auth-presence.test.ts index f4e10fa8693..95854350f7f 100644 --- a/src/main/rate-limits/codex-auth-presence.test.ts +++ b/src/main/rate-limits/codex-auth-presence.test.ts @@ -1,13 +1,13 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { existsSyncMock, homedirMock } = vi.hoisted(() => ({ - existsSyncMock: vi.fn(), +const { accessMock, homedirMock } = vi.hoisted(() => ({ + accessMock: vi.fn(), homedirMock: vi.fn() })) -vi.mock('node:fs', () => ({ - existsSync: existsSyncMock +vi.mock('node:fs/promises', () => ({ + access: accessMock })) vi.mock('node:os', () => ({ @@ -33,33 +33,101 @@ describe('codexAuthExists', () => { } }) - it('checks an explicit managed-account home first', () => { - existsSyncMock.mockReturnValue(true) + it('checks an explicit managed-account home first', async () => { + accessMock.mockResolvedValue(undefined) - expect(codexAuthExists('/managed/home')).toBe(true) - expect(existsSyncMock).toHaveBeenCalledWith(join('/managed/home', 'auth.json')) + await expect(codexAuthExists('/managed/home')).resolves.toBe(true) + expect(accessMock).toHaveBeenCalledWith(join('/managed/home', 'auth.json')) }) - it('falls back to CODEX_HOME when no home is provided', () => { + it('falls back to CODEX_HOME when no home is provided', async () => { process.env.CODEX_HOME = '/custom/codex' - existsSyncMock.mockReturnValue(true) + accessMock.mockResolvedValue(undefined) - expect(codexAuthExists()).toBe(true) - expect(existsSyncMock).toHaveBeenCalledWith(join('/custom/codex', 'auth.json')) + await expect(codexAuthExists()).resolves.toBe(true) + expect(accessMock).toHaveBeenCalledWith(join('/custom/codex', 'auth.json')) }) - it('falls back to ~/.codex when neither home nor CODEX_HOME is set', () => { - existsSyncMock.mockReturnValue(false) + it('falls back to ~/.codex when neither home nor CODEX_HOME is set', async () => { + accessMock.mockRejectedValue(new Error('ENOENT')) - expect(codexAuthExists()).toBe(false) - expect(existsSyncMock).toHaveBeenCalledWith(join('/home/alice', '.codex', 'auth.json')) + await expect(codexAuthExists()).resolves.toBe(false) + expect(accessMock).toHaveBeenCalledWith(join('/home/alice', '.codex', 'auth.json')) }) - it('returns false instead of throwing when the fs check fails', () => { - existsSyncMock.mockImplementation(() => { - throw new Error('EACCES') + it('returns false instead of throwing when the fs check fails', async () => { + accessMock.mockRejectedValue(new Error('EACCES')) + + await expect(codexAuthExists('/managed/home')).resolves.toBe(false) + }) + + it('stops waiting for a stalled filesystem check when the caller aborts', async () => { + let resolveAccess!: () => void + accessMock.mockReturnValue( + new Promise((resolve) => { + resolveAccess = resolve + }) + ) + const controller = new AbortController() + + const result = codexAuthExists('/managed/home', { signal: controller.signal }) + await Promise.resolve() + await Promise.resolve() + controller.abort() + + await expect(result).resolves.toBe(false) + resolveAccess() + await Promise.resolve() + }) + + it('bounds a stalled filesystem check even without a caller signal', async () => { + let resolveAccess!: () => void + accessMock.mockReturnValue( + new Promise((resolve) => { + resolveAccess = resolve + }) + ) + const timeoutController = new AbortController() + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeoutController.signal) + + const result = codexAuthExists('/managed/home') + expect(timeout).toHaveBeenCalledWith(5_000) + await Promise.resolve() + await Promise.resolve() + timeoutController.abort() + + await expect(result).resolves.toBe(false) + resolveAccess() + await Promise.resolve() + timeout.mockRestore() + }) + + it('shares one stalled UNC probe across concurrent callers', async () => { + let resolveAccess!: () => void + accessMock.mockReturnValue( + new Promise((resolve) => { + resolveAccess = resolve + }) + ) + const firstController = new AbortController() + const secondController = new AbortController() + + const first = codexAuthExists('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', { + signal: firstController.signal + }) + const second = codexAuthExists('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', { + signal: secondController.signal }) - expect(codexAuthExists('/managed/home')).toBe(false) + await Promise.resolve() + await Promise.resolve() + expect(accessMock).toHaveBeenCalledTimes(1) + firstController.abort() + secondController.abort() + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + + resolveAccess() + await Promise.resolve() }) }) diff --git a/src/main/rate-limits/codex-auth-presence.ts b/src/main/rate-limits/codex-auth-presence.ts index f972fcecbc0..8ebe81bd9be 100644 --- a/src/main/rate-limits/codex-auth-presence.ts +++ b/src/main/rate-limits/codex-auth-presence.ts @@ -1,6 +1,42 @@ -import { existsSync } from 'node:fs' +import { access } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' +import { + createAuthFilesystemOperation, + type SharedAuthFilesystemOperation +} from './auth-filesystem-operation' + +const AUTH_PRESENCE_TIMEOUT_MS = 5_000 +const authPresenceProbeByPath = new Map>() + +type CodexAuthPresenceOptions = { + signal?: AbortSignal + timeoutMs?: number +} + +function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation { + const existing = authPresenceProbeByPath.get(authPath) + if (existing) { + return existing + } + // Why: aborting a Node fs promise does not necessarily cancel an already + // issued UNC operation. Share the raw probe until it really settles so a + // disconnected WSL home cannot accumulate native requests across polls. + const probe = createAuthFilesystemOperation(authPath, () => + access(authPath).then( + () => true, + () => false + ) + ) + authPresenceProbeByPath.set(authPath, probe) + const clearProbe = (): void => { + if (authPresenceProbeByPath.get(authPath) === probe) { + authPresenceProbeByPath.delete(authPath) + } + } + void probe.result.then(clearProbe, clearProbe) + return probe +} // Why: the background quota poller spawns the real `codex` binary to read rate // limits. For users who installed Codex but never signed in, that spawn can @@ -8,12 +44,21 @@ import { join } from 'node:path' // the background. A signed-in Codex always writes an auth.json under its // CODEX_HOME, so gating the fetch on that file keeps the poller silent until // the user actually uses Codex. -export function codexAuthExists(codexHomePath?: string | null): boolean { +export async function codexAuthExists( + codexHomePath?: string | null, + options: CodexAuthPresenceOptions = {} +): Promise { // Mirror Codex's own home resolution: an explicit managed-account home wins, // then CODEX_HOME, then the default ~/.codex. const home = codexHomePath ?? process.env.CODEX_HOME ?? join(homedir(), '.codex') + const authPath = join(home, 'auth.json') + const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? AUTH_PRESENCE_TIMEOUT_MS) + const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal try { - return existsSync(join(home, 'auth.json')) + // Why: managed WSL homes are UNC paths. A synchronous stat can park + // Electron main while Windows wakes or reconnects the distro; the race + // also keeps a disconnected distro from serializing all later refreshes. + return await getAuthPresenceProbe(authPath).wait(signal) } catch { return false } diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts new file mode 100644 index 00000000000..8a0e2ee7028 --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -0,0 +1,194 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({ + childSpawnMock: vi.fn(), + readFileMock: vi.fn(), + ptySpawnMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) +vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) +vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) +vi.mock('./codex-auth-presence', () => ({ codexAuthExists: vi.fn(async () => true) })) + +import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher' + +describe('Codex backend rate-limit requests', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('uses the official backend usage contract without spawning Codex', async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ + tokens: { access_token: 'access-token', account_id: 'account-id' } + }) + ) + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + plan_type: 'plus', + rate_limit: { + primary_window: { + used_percent: 12, + limit_window_seconds: 3_600, + reset_at: 1_800_000_000 + }, + secondary_window: { + used_percent: 34, + limit_window_seconds: 86_400, + reset_at: 1_800_100_000 + } + }, + rate_limit_reset_credits: { available_count: 1 } + }) + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + available_count: 1, + credits: [ + { + status: 'available', + expires_at: '2027-01-15T12:00:00Z', + granted_at: '2027-01-08T12:00:00Z' + } + ] + }) + } as Response) + + await expect( + fetchCodexRateLimits({ + codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' + }) + ).resolves.toMatchObject({ + session: { usedPercent: 12, windowMinutes: 60, resetsAt: 1_800_000_000_000 }, + weekly: { usedPercent: 34, windowMinutes: 1440, resetsAt: 1_800_100_000_000 }, + rateLimitResetCredits: { + availableCount: 1, + nextExpiresAt: Date.parse('2027-01-15T12:00:00Z') + }, + status: 'ok' + }) + + expect(childSpawnMock).not.toHaveBeenCalled() + expect(ptySpawnMock).not.toHaveBeenCalled() + expect(fetch).toHaveBeenNthCalledWith( + 1, + 'https://chatgpt.com/backend-api/wham/usage', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer access-token' }), + signal: expect.any(AbortSignal) + }) + ) + }) + + it('aborts callers while sharing one stalled backend auth read', async () => { + let resolveRead!: (content: string) => void + readFileMock.mockImplementation( + () => + new Promise((resolve) => { + resolveRead = resolve + }) + ) + const firstController = new AbortController() + const secondController = new AbortController() + const codexHomePath = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' + + const first = fetchCodexRateLimits({ codexHomePath, signal: firstController.signal }) + const second = fetchCodexRateLimits({ codexHomePath, signal: secondController.signal }) + await vi.advanceTimersByTimeAsync(0) + expect(readFileMock).toHaveBeenCalledTimes(1) + expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining('auth.json'), 'utf8') + + firstController.abort() + secondController.abort() + + await expect(first).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + await expect(second).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + expect(fetch).not.toHaveBeenCalled() + expect(childSpawnMock).not.toHaveBeenCalled() + expect(ptySpawnMock).not.toHaveBeenCalled() + resolveRead('{}') + await vi.advanceTimersByTimeAsync(0) + }) + + it('applies the backend deadline while auth reading is still pending', async () => { + let resolveRead!: (content: string) => void + readFileMock.mockImplementation( + () => + new Promise((resolve) => { + resolveRead = resolve + }) + ) + const timeoutController = new AbortController() + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValueOnce(timeoutController.signal) + const deadlineError = new Error('backend deadline') + + const result = consumeCodexRateLimitResetCredit({ + codexHomePath: '/managed/deadline-home', + idempotencyKey: 'redeem-timeout' + }) + await vi.advanceTimersByTimeAsync(0) + // Why: redeem is user-triggered, so it gets the longer redeem deadline. + expect(timeout).toHaveBeenCalledWith(30_000) + expect(readFileMock).toHaveBeenCalledWith(join('/managed/deadline-home', 'auth.json'), 'utf8') + + timeoutController.abort(deadlineError) + + await expect(result).rejects.toBe(deadlineError) + resolveRead('{}') + await vi.advanceTimersByTimeAsync(0) + }) + + it('consumes a reset credit with the official payload and bounded request signal', async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ + tokens: { access_token: 'access-token', account_id: 'account-id' } + }) + ) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ code: 'already_redeemed' }) + } as Response) + + await expect( + consumeCodexRateLimitResetCredit({ + codexHomePath: '/managed/codex-home', + idempotencyKey: 'redeem-123' + }) + ).resolves.toBe('alreadyRedeemed') + + expect(fetch).toHaveBeenCalledWith( + 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ redeem_request_id: 'redeem-123' }), + signal: expect.any(AbortSignal), + headers: expect.objectContaining({ + Authorization: 'Bearer access-token', + 'ChatGPT-Account-Id': 'account-id', + 'Content-Type': 'application/json' + }) + }) + ) + }) +}) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 77532918702..a2031ac9941 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -77,7 +77,7 @@ describe('fetchCodexRateLimits', () => { vi.useFakeTimers() vi.clearAllMocks() resolveCodexCommandMock.mockReturnValue('codex') - vi.mocked(codexAuthExists).mockReturnValue(true) + vi.mocked(codexAuthExists).mockResolvedValue(true) readFileMock.mockRejectedValue(new Error('no auth fixture')) vi.stubGlobal('fetch', vi.fn()) }) @@ -87,7 +87,7 @@ describe('fetchCodexRateLimits', () => { }) it('does not spawn Codex when the user is not signed in', async () => { - vi.mocked(codexAuthExists).mockReturnValue(false) + vi.mocked(codexAuthExists).mockResolvedValue(false) await expect(fetchCodexRateLimits()).resolves.toMatchObject({ provider: 'codex', @@ -101,6 +101,28 @@ describe('fetchCodexRateLimits', () => { expect(ptySpawnMock).not.toHaveBeenCalled() }) + it('preserves the aborted result when cancellation lands during the auth check', async () => { + let resolveAuth!: (exists: boolean) => void + vi.mocked(codexAuthExists).mockReturnValueOnce( + new Promise((resolve) => { + resolveAuth = resolve + }) + ) + const controller = new AbortController() + + const result = fetchCodexRateLimits({ signal: controller.signal }) + controller.abort() + resolveAuth(false) + + await expect(result).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + expect(childSpawnMock).not.toHaveBeenCalled() + expect(ptySpawnMock).not.toHaveBeenCalled() + }) + it('disposes node-pty listeners before killing the PTY fallback on timeout', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() @@ -133,6 +155,7 @@ describe('fetchCodexRateLimits', () => { childSpawnMock.mockReturnValue(rpcChild) const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + await vi.advanceTimersByTimeAsync(0) const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string expect(spawnCwd).toContain('rate-limit-pty-cwd') @@ -168,6 +191,7 @@ describe('fetchCodexRateLimits', () => { const controller = new AbortController() const resultPromise = fetchCodexRateLimits({ signal: controller.signal }) + await vi.advanceTimersByTimeAsync(0) controller.abort() @@ -221,6 +245,7 @@ describe('fetchCodexRateLimits', () => { }) const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(0) rpcChild.emit('close') await vi.advanceTimersByTimeAsync(0) @@ -247,6 +272,7 @@ describe('fetchCodexRateLimits', () => { childSpawnMock.mockReturnValue(rpcChild) const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + await vi.advanceTimersByTimeAsync(0) rpcChild.emit('close') await vi.advanceTimersByTimeAsync(0) @@ -421,6 +447,7 @@ describe('fetchCodexRateLimits', () => { expect(fetch).toHaveBeenCalledWith( 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', expect.objectContaining({ + signal: expect.any(AbortSignal), headers: expect.objectContaining({ Authorization: 'Bearer access-token', 'ChatGPT-Account-Id': 'account-id', @@ -553,6 +580,8 @@ describe('fetchCodexRateLimits', () => { ) expect(shellCommand.match(/<&3 >&4 3<&- 4>&-/g)).toHaveLength(3) expect(shellCommand.match(/exec codex [^\n]+<&3 >&4 3<&- 4>&-/g)).toHaveLength(3) + expect(shellCommand).not.toContain('_orca_codex') + expect(shellCommand).not.toContain('wsl-codex-path') expect(spawnOptions).toEqual( expect.objectContaining({ cwd: expect.stringContaining('rate-limit-pty-cwd'), @@ -649,6 +678,7 @@ describe('fetchCodexRateLimits', () => { const resultPromise = fetchCodexRateLimits({ codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' }) + await vi.advanceTimersByTimeAsync(0) rpcChild.emit('close') await vi.advanceTimersByTimeAsync(0) @@ -668,6 +698,8 @@ describe('fetchCodexRateLimits', () => { "export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''" ) expect(shellCommand).toContain('exec codex ') + expect(shellCommand).not.toContain('_orca_codex') + expect(shellCommand).not.toContain('wsl-codex-path') expect(spawnOptions).toEqual( expect.objectContaining({ cwd: expect.stringContaining('rate-limit-pty-cwd'), diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index c75be611fe4..8a3ed94bdff 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -25,10 +25,18 @@ import { getHiddenRateLimitWslCwdSetupCommands, resolveHiddenRateLimitPtyCwd } from './hidden-rate-limit-pty-cwd' +import { + createAuthFilesystemOperation, + type SharedAuthFilesystemOperation +} from './auth-filesystem-operation' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 const PTY_TIMEOUT_MS = 15_000 +const BACKEND_TIMEOUT_MS = 10_000 +// Why: redeeming a reset credit is an explicit user action, not a background +// poll — give it more room before failing so a slow backend can still finish. +const REDEEM_BACKEND_TIMEOUT_MS = 30_000 const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000 export type FetchCodexRateLimitsOptions = { @@ -102,6 +110,21 @@ type BackendRateLimitResetCreditsResponse = { }[] } +type BackendRateLimitWindow = { + used_percent?: number + limit_window_seconds?: number + reset_at?: number +} + +type BackendUsageResponse = { + plan_type?: string + rate_limit?: { + primary_window?: BackendRateLimitWindow | null + secondary_window?: BackendRateLimitWindow | null + } | null + rate_limit_reset_credits?: BackendRateLimitResetCreditsResponse | null +} + type BackendConsumeRateLimitResetCreditResponse = { code?: string } @@ -110,6 +133,15 @@ type CodexBackendAuthHeaders = { headers: Record } +type BackendAuthReadResult = + | { content: string; error?: never } + | { content?: never; error: unknown } + +const backendAuthReadByPath = new Map< + string, + SharedAuthFilesystemOperation +>() + function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } @@ -126,14 +158,19 @@ function buildWslCodexCommand( if (process.platform !== 'win32' || !wslInfo) { return null } - const script = [ + const setupCommands = [ ...getHiddenRateLimitWslCwdSetupCommands(), - `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`, - `exec codex ${args.map(shellQuote).join(' ')}${ - options?.isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : '' - }` + `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}` ].join(' && ') - const loginShellCommand = buildWslLoginShellCommand(script) + const execSuffix = `${args.map(shellQuote).join(' ')}${ + options?.isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : '' + }` + // Why: npm/nvm Codex launchers use `#!/usr/bin/env node`. Resolving an + // absolute launcher in a login shell and later execing it from plain `sh` + // loses the PATH that supplies Node and also pins obsolete installations. + const loginShellCommand = buildWslLoginShellCommand( + [setupCommands, `exec codex ${execSuffix}`].join(' && ') + ) // Why: keep the outer sh non-login and hide RPC pipes before the configured // shell startup can read input or print banners. const command = options?.isolateRpcStdio @@ -255,14 +292,57 @@ function mapBackendRateLimitResetCredits( } } +function createBackendRequestSignal( + callerSignal?: AbortSignal, + timeoutMs = BACKEND_TIMEOUT_MS +): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeoutMs) + return callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal +} + +function getBackendAuthRead( + authPath: string +): SharedAuthFilesystemOperation { + const existing = backendAuthReadByPath.get(authPath) + if (existing) { + return existing + } + // Why: the caller deadline must settle promptly, but Node cannot guarantee + // cancellation of an already-issued UNC read. Keep one raw read per auth + // path until the OS finishes so repeated quota refreshes cannot stack them. + const read = createAuthFilesystemOperation(authPath, () => + readFile(authPath, 'utf8').then( + (content) => ({ content }), + (error: unknown) => ({ error }) + ) + ) + backendAuthReadByPath.set(authPath, read) + const clearRead = (): void => { + if (backendAuthReadByPath.get(authPath) === read) { + backendAuthReadByPath.delete(authPath) + } + } + void read.result.then(clearRead, clearRead) + return read +} + +async function readBackendAuth(authPath: string, signal: AbortSignal): Promise { + const result = await getBackendAuthRead(authPath).wait(signal) + if ('error' in result) { + throw result.error + } + return result.content +} + async function getCodexBackendAuthHeaders( - options?: FetchCodexRateLimitsOptions + options: FetchCodexRateLimitsOptions | undefined, + signal: AbortSignal ): Promise { - if (options?.signal?.aborted) { + if (signal.aborted) { return null } const authPath = join(getCodexHomePath(options?.codexHomePath), 'auth.json') - const auth = JSON.parse(await readFile(authPath, 'utf8')) as CodexAuthFile + const auth = JSON.parse(await readBackendAuth(authPath, signal)) as CodexAuthFile const accessToken = auth.tokens?.access_token if (!accessToken) { return null @@ -286,18 +366,19 @@ async function fetchBackendRateLimitResetCredits( if (options?.signal?.aborted) { return null } - const auth = await getCodexBackendAuthHeaders(options) + const signal = createBackendRequestSignal(options?.signal) + const auth = await getCodexBackendAuthHeaders(options, signal) if (!auth) { return null } - if (options?.signal?.aborted) { + if (signal.aborted) { return null } // Why: published Codex 0.140 can read windows through app-server but strips // reset-credit metadata that the backend already returns. const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', { ...auth, - signal: options?.signal + signal }) if (!response.ok) { return null @@ -349,7 +430,8 @@ export async function consumeCodexRateLimitResetCredit(options: { if (!options.idempotencyKey.trim()) { throw new Error('Codex reset idempotency key is required') } - const auth = await getCodexBackendAuthHeaders(options) + const signal = createBackendRequestSignal(undefined, REDEEM_BACKEND_TIMEOUT_MS) + const auth = await getCodexBackendAuthHeaders(options, signal) if (!auth) { throw new Error('Codex not signed in') } @@ -361,7 +443,8 @@ export async function consumeCodexRateLimitResetCredit(options: { ...auth.headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ redeem_request_id: options.idempotencyKey }) + body: JSON.stringify({ redeem_request_id: options.idempotencyKey }), + signal } ) if (!response.ok) { @@ -375,13 +458,13 @@ function mapRpcWindow( raw: RpcRateWindow | undefined, expectedWindowMinutes: number ): RateLimitWindow | null { - if (!raw || typeof raw.usedPercent !== 'number') { + if (!raw || typeof raw.usedPercent !== 'number' || !Number.isFinite(raw.usedPercent)) { return null } let resetDescription: string | null = null let resetsAt: number | null = null - if (raw.resetsAt) { + if (typeof raw.resetsAt === 'number' && Number.isFinite(raw.resetsAt) && raw.resetsAt > 0) { // Why: Codex returns resetsAt as Unix seconds, not milliseconds. const date = new Date(raw.resetsAt * 1000) if (!Number.isNaN(date.getTime())) { @@ -408,6 +491,71 @@ function mapRpcWindow( } } +function mapBackendUsageWindow( + raw: BackendRateLimitWindow | null | undefined, + fallbackWindowMinutes: number +): RateLimitWindow | null { + const limitWindowSeconds = raw?.limit_window_seconds + // Match Codex backend-client's `window_minutes_from_seconds`: the backend + // field is the actual bucket duration and rounds partial minutes upward. + const windowMinutes = + typeof limitWindowSeconds === 'number' && + Number.isFinite(limitWindowSeconds) && + limitWindowSeconds > 0 + ? Math.ceil(limitWindowSeconds / 60) + : fallbackWindowMinutes + return mapRpcWindow( + raw + ? { + usedPercent: raw.used_percent, + resetsAt: raw.reset_at + } + : undefined, + windowMinutes + ) +} + +async function fetchViaBackend( + options?: FetchCodexRateLimitsOptions +): Promise { + const signal = createBackendRequestSignal(options?.signal) + const auth = await getCodexBackendAuthHeaders(options, signal) + if (!auth || signal.aborted) { + return null + } + // Why: Codex itself reads this endpoint in backend-client's + // `get_rate_limit_status`; using the same contract avoids launching a hidden + // app-server (and a WSL login shell) for every routine quota refresh. + const response = await fetch('https://chatgpt.com/backend-api/wham/usage', { + ...auth, + signal + }) + if (!response.ok) { + return null + } + const payload = (await response.json()) as BackendUsageResponse + // `plan_type` is required by Codex's RateLimitStatusPayload. Reject a + // superficially successful but unrelated/malformed JSON response so the + // established app-server fallback still gets a chance. + if (typeof payload.plan_type !== 'string') { + return null + } + return { + provider: 'codex', + session: mapBackendUsageWindow(payload.rate_limit?.primary_window, 300), + weekly: mapBackendUsageWindow(payload.rate_limit?.secondary_window, 10080), + ...(payload.rate_limit_reset_credits !== undefined + ? { + rateLimitResetCredits: + mapBackendRateLimitResetCredits(payload.rate_limit_reset_credits) ?? null + } + : {}), + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + // --------------------------------------------------------------------------- // RPC fetch — spawn `codex -s read-only -a untrusted app-server` // --------------------------------------------------------------------------- @@ -878,7 +1026,13 @@ export async function fetchCodexRateLimits( // Why: never spawn the `codex` binary unless the user has signed in. Without // auth the RPC/PTY paths can only error, and spawning them shows up as an // unexpected background Codex process for users who don't use Codex. - if (!codexAuthExists(options?.codexHomePath)) { + const hasCodexAuth = await codexAuthExists(options?.codexHomePath, { + signal: options?.signal + }) + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } + if (!hasCodexAuth) { return { provider: 'codex', session: null, @@ -889,7 +1043,29 @@ export async function fetchCodexRateLimits( } } - // Path A: try RPC first + // Path A (WSL): use Codex's own backend usage contract. Host accounts retain + // app-server's token-refresh/custom-CA behavior; WSL avoids starting a login + // shell just to reconstruct the CLI environment for a routine poll. + if (options?.codexHomePath && parseWslUncPath(options.codexHomePath)) { + try { + const backendResult = await fetchViaBackend(options) + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } + if (backendResult) { + const withResetCredits = await withBackendRateLimitResetCredits(backendResult, options) + return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits + } + } catch { + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } + // Token refresh, network routing, and custom-CA behavior can differ from + // the host fetch stack. Preserve the CLI paths as compatibility fallbacks. + } + } + + // Path B: try RPC try { const rpcResult = await fetchViaRpc(options) if (options?.signal?.aborted) { @@ -924,7 +1100,7 @@ export async function fetchCodexRateLimits( // RPC failed — fall through to PTY } - // Path B: PTY fallback + // Path C: PTY fallback try { if (options?.signal?.aborted) { return abortedCodexRateLimitResult() diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index eac3b6c6411..f72d94c4f9f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -14898,7 +14898,24 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService({ ...store, getAllWorktreeMeta: () => metaById, - getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getWorkspaceSession: () => ({ + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf-1': { + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: 'test', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + } + }) } as never) runtime.setNotifier({ worktreesChanged: vi.fn(), @@ -14915,13 +14932,13 @@ describe('OrcaRuntimeService', () => { terminalFitOverrideChanged: vi.fn(), terminalDriverChanged: vi.fn() }) - // A renderer must be attached to receive the wake (headless serve is a - // deliberate non-goal — no renderer, no wake). + // A renderer must be attached to receive the wake — headless serve reports + // the explicit 'unsupported-headless' outcome instead (see below). electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) runtime.attachWindow(TEST_WINDOW_ID) runtime.markGraphReady(TEST_WINDOW_ID) - await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { notifyClients: false, clientKind: 'mobile' }) @@ -14930,6 +14947,86 @@ describe('OrcaRuntimeService', () => { // routes exclusively through the renderer's own navigation-free wake. expect(resumeSleepingAgents).toHaveBeenCalledWith(TEST_WORKTREE_ID) expect(activateWorktree).not.toHaveBeenCalled() + expect(result.sleepingAgentWake).toBe('requested') + }) + + it('reports the wake as unsupported when a phone activates a worktree on headless serve', async () => { + // Why: without an attached renderer nothing holds the sleeping records, so + // no agent actually wakes. The result must say so explicitly — activating + // silently would let the phone present slept agents as resumed (#7906). + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false }) + } + const resumeSleepingAgents = vi.fn() + const getWorkspaceSession = vi.fn(() => ({ + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf-1': { + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: 'test', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + } + })) + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getWorkspaceSession + } as never) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + resumeSleepingAgents, + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + // Headless/no-live-renderer: the graph is ready but no BrowserWindow backs + // the authoritative id, so getAvailableAuthoritativeWindow() is null. + electronMocks.BrowserWindow.fromId.mockReturnValue(null as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'mobile' + }) + + expect(result.activated).toBe(true) + expect(result.sleepingAgentWake).toBe('unsupported-headless') + // Why: sleeping records are host-partitioned; the check must read the + // repo's execution host partition, not always the local one. + expect(getWorkspaceSession).toHaveBeenCalledWith('local') + expect(resumeSleepingAgents).not.toHaveBeenCalled() + }) + + it('does not report headless wake degradation without sleeping records', async () => { + const runtime = new OrcaRuntimeService(store as never) + electronMocks.BrowserWindow.fromId.mockReturnValue(null as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'mobile' + }) + + expect(result.sleepingAgentWake).toBe('not-applicable') }) it('does not wake slept agents for non-mobile session-only activation', async () => { @@ -14963,12 +15060,13 @@ describe('OrcaRuntimeService', () => { // INV-3: web/desktop runtime clients keep their existing wake-on-activation // paths untouched — the renderer notifier wake is mobile-scoped. - await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { notifyClients: false, clientKind: 'runtime' }) expect(resumeSleepingAgents).not.toHaveBeenCalled() + expect(result.sleepingAgentWake).toBe('not-applicable') }) it('does not rewrite unread metadata when a mobile activation finds the worktree already read', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 546d40a5730..43ea05133bb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -127,7 +127,11 @@ import type { WorkspaceSessionState, DirEntry } from '../../shared/types' -import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' @@ -12622,6 +12626,10 @@ export class OrcaRuntimeService { repoId: string worktreeId: string activated: boolean + /** Mobile-scoped slept-agent wake outcome. `unsupported-headless` means no + * renderer holds the sleeping records (headless `orca serve`), so nothing + * woke — clients must not present the worktree's agents as resumed. */ + sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' }> { this.assertGraphReady() const worktree = await this.resolveWorktreeSelector(worktreeSelector) @@ -12637,6 +12645,8 @@ export class OrcaRuntimeService { this.notifyWorktreesChanged(repo.id) } + let sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' = + 'not-applicable' if (opts.notifyClients !== false) { // Why: inactive worktree terminal panes are renderer-owned and may not have // live PTYs until the desktop activates the worktree and mounts them. @@ -12652,12 +12662,29 @@ export class OrcaRuntimeService { // Why: a phone open must also wake the worktree's slept agents (experimental // agent sleep). Only the host renderer holds the sleeping records + wake // authority, so fire-and-forget ask it — mobile-scoped so web/desktop are - // unaffected, and only when a renderer is attached to receive it. - if (opts.clientKind === 'mobile' && this.getAvailableAuthoritativeWindow()) { - this.notifier?.resumeSleepingAgents?.(worktree.id) + // unaffected. Headless serve has no renderer to wake anything, so report + // that explicitly instead of letting mobile assume the agents resumed. + if (opts.clientKind === 'mobile') { + if (this.getAvailableAuthoritativeWindow()) { + this.notifier?.resumeSleepingAgents?.(worktree.id) + sleepingAgentWake = 'requested' + } else if ( + // Why: sleeping records are partitioned by execution host; reading + // only the local partition would miss slept agents on SSH-host + // worktrees and skip the headless warning for them. + Object.values( + this.store?.getWorkspaceSession?.(getRepoExecutionHostId(repo)) + .sleepingAgentSessionsByPaneKey ?? {} + ).some((record) => record.worktreeId === worktree.id) + ) { + // Why: headless is only degraded when this worktree actually has a + // persisted resume record. Ordinary mobile activation must not show + // an unsupported warning merely because no desktop window is open. + sleepingAgentWake = 'unsupported-headless' + } } } - return { repoId: repo.id, worktreeId: worktree.id, activated: true } + return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake } } private async buildStartupForDraft( diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index eb34e42b62e..ce443c55454 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7,6 +7,7 @@ import { useMemo, useRef, useState, + useSyncExternalStore, type SetStateAction } from 'react' import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' @@ -170,6 +171,10 @@ import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state' import { translate } from '@/i18n/i18n' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' +import { + hasRequestedBackgroundTerminalWorktreeMount, + subscribeBackgroundTerminalWorktreeMountRequests +} from './components/terminal/background-terminal-worktree-mount' const isMac = navigator.userAgent.includes('Mac') const isWindows = !isMac && navigator.userAgent.includes('Windows') @@ -481,6 +486,11 @@ function App(): React.JSX.Element { const worktreeSidebarScrollAnchorRef = useRef(null) const floatingVisibleTabCount = useAppStore(selectFloatingVisibleTabCount) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) + const backgroundTerminalMountRequested = useSyncExternalStore( + subscribeBackgroundTerminalWorktreeMountRequests, + hasRequestedBackgroundTerminalWorktreeMount, + hasRequestedBackgroundTerminalWorktreeMount + ) const keybindings = useAppStore((s) => s.keybindings) const updateStatus = useAppStore((s) => s.updateStatus) const activeContextualTourId = useAppStore((s) => s.activeContextualTourId) @@ -497,14 +507,16 @@ function App(): React.JSX.Element { floatingTerminalEnabled && (floatingTerminalTriggerLocation === 'floating-button' || !statusBarVisible) const hasMountedTerminalWorkbenchRef = useRef(false) - if (activeWorktreeId !== null) { + if (activeWorktreeId !== null || backgroundTerminalMountRequested) { hasMountedTerminalWorkbenchRef.current = true } // Why: skip the terminal bundle on the no-workspace landing path, but once a // workspace has mounted, keep Terminal-owned hidden panes alive through sleep // and shutdown transitions where activeWorktreeId can briefly become null. const shouldMountTerminalWorkbench = - activeWorktreeId !== null || hasMountedTerminalWorkbenchRef.current + activeWorktreeId !== null || + backgroundTerminalMountRequested || + hasMountedTerminalWorkbenchRef.current // Why: visible worktree creation owns its faux tab strip from start to finish; // the previous workspace must stay mounted for retention without rendering // real chrome. diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 6109a53d890..119ac5f77e0 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -166,7 +166,9 @@ describe('renderer startup runtime routing', () => { expect(source).not.toContain("from './components/Terminal'") expect(source).toContain('const hasMountedTerminalWorkbenchRef = useRef(false)') expect(source).toContain('hasMountedTerminalWorkbenchRef.current = true') - expect(source).toContain('activeWorktreeId !== null || hasMountedTerminalWorkbenchRef.current') + expect(source).toContain('activeWorktreeId !== null || backgroundTerminalMountRequested') + expect(source).toContain('backgroundTerminalMountRequested ||') + expect(source).toContain('hasMountedTerminalWorkbenchRef.current') expect(source).toContain('shouldMountTerminalWorkbench ?') }) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index e2d4b5892ea..c4d331697c2 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -59,6 +59,13 @@ import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer' import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal' import { resolveRepairedActiveTerminalTabId } from './terminal/active-terminal-repair' import { scheduleBackgroundTerminalWorktreeMeasure } from './terminal/background-terminal-worktree-visibility' +import { + applyBackgroundMountTabRestriction, + pruneClosedBackgroundMountTabs, + shouldMountBackgroundWorktreeTab, + takeAllPendingBackgroundTerminalWorktreeMounts, + takePendingBackgroundTerminalWorktreeMount +} from './terminal/background-terminal-worktree-mount' import { getEffectiveLayoutForWorktree as getEffectiveLayout, anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout @@ -730,13 +737,22 @@ function Terminal(): React.JSX.Element | null { // Only mount TerminalPanes for visited worktrees to prevent mass PTY // spawning when restoring a session with many saved worktree tabs. const measurableBackgroundWorktreeTimersRef = useRef(new Map()) + // Why: background-mounted worktrees restricted to specific tabs (targeted + // wake/resume) must not instantiate a TerminalPane per saved tab. A worktree + // absent from this map mounts all of its tabs. + const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) const [, setBackgroundMountRevision] = useState(0) useEffect(() => { const timers = measurableBackgroundWorktreeTimersRef.current const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current - const onBackgroundMountTerminalWorktree = (event: Event): void => { - const customEvent = event as CustomEvent - const worktreeId = customEvent.detail?.worktreeId + const applyBackgroundMount = (detail: BackgroundMountTerminalWorktreeDetail): void => { + const worktreeId = detail.worktreeId + applyBackgroundMountTabRestriction( + backgroundMountTabIdsByWorktreeRef.current, + mountedWorktreeIdsRef.current, + worktreeId, + detail.tabIds + ) scheduleBackgroundTerminalWorktreeMeasure({ mountedWorktreeIds: mountedWorktreeIdsRef.current, measurableBackgroundWorktreeIds: measurableBackgroundWorktreeIdsRef.current, @@ -747,10 +763,24 @@ function Terminal(): React.JSX.Element | null { clearTimeoutFn: window.clearTimeout }) } + const onBackgroundMountTerminalWorktree = (event: Event): void => { + const customEvent = event as CustomEvent + const worktreeId = customEvent.detail?.worktreeId + const pending = takePendingBackgroundTerminalWorktreeMount(worktreeId) + const detail = pending ?? customEvent.detail + if (detail?.worktreeId) { + applyBackgroundMount(detail) + } + } window.addEventListener( BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onBackgroundMountTerminalWorktree as EventListener ) + // Requests made while the lazy Terminal bundle/effect was absent stay in + // the registry and are replayed only after the listener owns the surface. + for (const pending of takeAllPendingBackgroundTerminalWorktreeMounts()) { + applyBackgroundMount(pending) + } return () => { window.removeEventListener( BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, @@ -775,12 +805,21 @@ function Terminal(): React.JSX.Element | null { // creating a duplicate PTY for the same tab. if (renderedActiveWorktreeId && workspaceSessionReady) { mountedWorktreeIdsRef.current.add(renderedActiveWorktreeId) + // Why: a real activation supersedes any targeted background mount — the + // visible worktree needs all of its tabs. + backgroundMountTabIdsByWorktreeRef.current.delete(renderedActiveWorktreeId) } + pruneClosedBackgroundMountTabs( + backgroundMountTabIdsByWorktreeRef.current, + mountedWorktreeIdsRef.current, + tabsByWorktree + ) // Prune IDs of worktrees that no longer exist (deleted/removed) const allWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id)) for (const id of mountedWorktreeIdsRef.current) { if (!allWorktreeIds.has(id)) { mountedWorktreeIdsRef.current.delete(id) + backgroundMountTabIdsByWorktreeRef.current.delete(id) } } const anyMountedWorktreeHasLayout = computeAnyMountedWorktreeHasLayout( @@ -1830,6 +1869,9 @@ function Terminal(): React.JSX.Element | null { isVisible={isVisible} shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree} activityTerminalPortals={activityTerminalPortals} + backgroundMountTabIds={ + backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null + } /> ) })} @@ -1893,47 +1935,56 @@ function Terminal(): React.JSX.Element | null { aria-hidden={!isVisible} > - {(tabsByWorktree[workspace.id] ?? []).map((tab) => { - const activityTerminalPortal = findActivityTerminalPortal( - activityTerminalPortals, - { worktreeId: workspace.id, tabId: tab.id } - ) - const isActivityPortalTab = activityTerminalPortal !== null - const isActiveTerminalTab = - isVisible && tab.id === activeTabId && activeTabType === 'terminal' - const terminalPane = ( - handlePtyExit(tab.id, ptyId)} - onCloseTab={() => handleCloseTab(tab.id)} - /> - ) - if (activityTerminalPortal) { - return createPortal( - terminalPane, - activityTerminalPortal.target, - `activity-terminal-${tab.id}` + {(tabsByWorktree[workspace.id] ?? []) + .filter((tab) => + shouldMountBackgroundWorktreeTab( + backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null, + tab.id ) - } - return terminalPane - })} + ) + .map((tab) => { + const activityTerminalPortal = findActivityTerminalPortal( + activityTerminalPortals, + { worktreeId: workspace.id, tabId: tab.id } + ) + const isActivityPortalTab = activityTerminalPortal !== null + const isActiveTerminalTab = + isVisible && tab.id === activeTabId && activeTabType === 'terminal' + const terminalPane = ( + handlePtyExit(tab.id, ptyId)} + onCloseTab={() => handleCloseTab(tab.id)} + /> + ) + if (activityTerminalPortal) { + return createPortal( + terminalPane, + activityTerminalPortal.target, + `activity-terminal-${tab.id}` + ) + } + return terminalPane + })} ) })} @@ -2107,7 +2158,8 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ focusedGroupId, isVisible, shouldMeasureHiddenWorktree, - activityTerminalPortals + activityTerminalPortals, + backgroundMountTabIds }: { worktreeId: string worktreePath: string @@ -2116,6 +2168,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ isVisible: boolean shouldMeasureHiddenWorktree: boolean activityTerminalPortals: ActivityTerminalPortalTarget[] + backgroundMountTabIds: ReadonlySet | null }): React.JSX.Element { const browserPageIds = useAppStore( useShallow((state) => @@ -2155,9 +2208,14 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ worktreePath={worktreePath} isWorktreeActive={isVisible} activityTerminalPortals={activityTerminalPortals} + backgroundMountTabIds={backgroundMountTabIds} /> - - + {isVisible || backgroundMountTabIds === null ? ( + <> + + + + ) : null} ) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 0e68af9126f..8e1c8f17d8d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -11,6 +11,7 @@ import { } from '../activity/activity-terminal-portal' import TerminalPane from './TerminalPane' import { closeTerminalTab } from '../terminal/terminal-tab-actions' +import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount' import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut' type TerminalOverlayAssignment = { @@ -278,12 +279,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId, worktreePath, isWorktreeActive, - activityTerminalPortals = EMPTY_ACTIVITY_PORTALS + activityTerminalPortals = EMPTY_ACTIVITY_PORTALS, + backgroundMountTabIds = null }: { worktreeId: string worktreePath: string isWorktreeActive: boolean activityTerminalPortals?: ActivityTerminalPortalTarget[] + /** Non-null for targeted background mounts: only these terminal tabs get a + * TerminalPane, so waking one slept agent does not connect every saved tab. */ + backgroundMountTabIds?: ReadonlySet | null }): React.JSX.Element | null { const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore( useShallow((state) => ({ @@ -352,34 +357,38 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ return ( <> - {terminalTabs.map((terminalTab) => { - const assignment = assignments.get(terminalTab.id) - const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) - const isActive = Boolean(isVisible && assignment && assignment.groupId === activeGroupId) - const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { - worktreeId, - tabId: terminalTab.id - }) - return ( - + {terminalTabs + .filter((terminalTab) => + shouldMountBackgroundWorktreeTab(backgroundMountTabIds, terminalTab.id) ) - })} + .map((terminalTab) => { + const assignment = assignments.get(terminalTab.id) + const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) + const isActive = Boolean(isVisible && assignment && assignment.groupId === activeGroupId) + const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { + worktreeId, + tabId: terminalTab.id + }) + return ( + + ) + })} ) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 63a28b7819a..9368f810cbf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -174,6 +174,7 @@ type StoreState = { agentStatusByPaneKey: Record paneForegroundAgentByPaneKey: Record sleepingAgentSessionsByPaneKey: Record + suppressedPtyExitIds: Record agentLaunchConfigByPaneKey: Record getAgentLaunchConfigForStatusEntry: ReturnType getAgentLaunchConfigForStatusMetadata: ReturnType @@ -748,6 +749,7 @@ describe('connectPanePty', () => { agentStatusByPaneKey: {}, paneForegroundAgentByPaneKey: {}, sleepingAgentSessionsByPaneKey: {}, + suppressedPtyExitIds: {}, agentLaunchConfigByPaneKey: {}, getAgentLaunchConfigForStatusEntry: vi.fn((entry: { paneKey: string }) => { return mockStoreState.agentLaunchConfigByPaneKey[entry.paneKey]?.launchConfig @@ -1830,6 +1832,7 @@ describe('connectPanePty', () => { updatedAt: 1, origin: 'worktree-sleep' } + mockStoreState.suppressedPtyExitIds['tab-pty'] = true const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { noteVisibilityResume: () => void @@ -1898,9 +1901,10 @@ describe('connectPanePty', () => { updatedAt: 1, origin: 'worktree-sleep' } + mockStoreState.suppressedPtyExitIds['tab-pty'] = true const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - wakeHibernatedAgentIfArmed: () => void + wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null dispose: () => void } await flushAsyncTicks() @@ -1913,7 +1917,14 @@ describe('connectPanePty', () => { // Still hidden: no reveal happened, so nothing respawned on exit. expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) - binding.wakeHibernatedAgentIfArmed() + // The wake reports the claim it consumed so the dispatcher's generic + // resume never launches the same provider session into a second tab. + const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-bg' + expect(binding.wakeHibernatedAgentIfArmed(new Set([claimKey]))).toBeNull() + expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) + const claimedProviderSessions = new Set() + expect(binding.wakeHibernatedAgentIfArmed(claimedProviderSessions)).toBe(claimKey) + expect(claimedProviderSessions).toEqual(new Set([claimKey])) await flushAsyncTicks() expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) @@ -1930,6 +1941,183 @@ describe('connectPanePty', () => { expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) }) + it('latches a navigation-free wake that lands before the hibernation kill arms the pane', async () => { + // Race (#7906): mobile opens the worktree after the sleeping record is + // written but before the suppressed kill's exit sets hibernatedWakePtyId. + // The wake is edge-triggered and the phone never reveals the desktop pane, + // so without a latch the wake would be dropped and the phone left staring + // at a frozen terminal. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-race' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + mockStoreState.suppressedPtyExitIds['tab-pty'] = true + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null + dispose: () => void + } + await flushAsyncTicks() + expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') + + // Wake arrives mid-kill: nothing is armed yet, but the pane must claim the + // session (suppressing the generic resume) and latch the request. + const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-race' + expect(binding.wakeHibernatedAgentIfArmed(new Set([claimKey]))).toBeNull() + const claimedProviderSessions = new Set() + expect(binding.wakeHibernatedAgentIfArmed(claimedProviderSessions)).toBe(claimKey) + expect(claimedProviderSessions).toEqual(new Set([claimKey])) + const connectCallsBeforeExit = transport.connect.mock.calls.length + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + onPtyExit?.('tab-pty') + await flushAsyncTicks() + + // Arming consumed the latched wake — the --resume spawned with no reveal + // and no second wake event. + expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) + const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as + | { command?: string } + | undefined + expect(resumeConnectOptions?.command).toContain('--resume') + expect(resumeConnectOptions?.command).toContain('sess-hibernated-race') + }) + + it('keeps an in-place provider claim until the replacement PTY spawn settles', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-inflight' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + const binding = connectPanePty( + createPane(2) as never, + createManager(1) as never, + deps as never + ) as unknown as { + wakeHibernatedAgentIfArmed: () => string | null + } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + onPtyExit?.('tab-pty') + await flushAsyncTicks() + const deferredSpawn = createDeferred() + transport.connect.mockImplementationOnce(() => deferredSpawn.promise) + + const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-inflight' + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) + const connectCallsAfterFirstWake = transport.connect.mock.calls.length + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) + expect(transport.connect.mock.calls.length).toBe(connectCallsAfterFirstWake) + + deferredSpawn.resolve('pty-resumed') + await flushAsyncTicks() + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) + }) + + it('re-arms the exact hibernation target after a replacement spawn fails', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-retry' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + const binding = connectPanePty( + createPane(2) as never, + createManager(1) as never, + createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) as never + ) as unknown as { wakeHibernatedAgentIfArmed: () => string | null } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + onPtyExit?.('tab-pty') + await flushAsyncTicks() + transport.connect.mockRejectedValueOnce(new Error('transient spawn failure')) + + const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-retry' + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) + await flushAsyncTicks(20) + const connectCallsAfterFailure = transport.connect.mock.calls.length + + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) + expect(transport.connect.mock.calls.length).toBe(connectCallsAfterFailure + 1) + }) + + it('does not latch a stale sleeping record beside an unsuppressed live PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-stale' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + const binding = connectPanePty( + createPane(2) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) as unknown as { + wakeHibernatedAgentIfArmed: () => string | null + } + await flushAsyncTicks() + + expect(binding.wakeHibernatedAgentIfArmed()).toBeNull() + }) + it('auto-resumes a hibernated pane when its kill lands after the pane is already revealed', async () => { // Race: the user reveals the background tab in the window between the // coordinator confirming the candidate and the kill's exit arriving. The diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index df753a47114..e86afd8018e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -88,6 +88,10 @@ import { } from '@/lib/pane-manager/terminal-scroll-intent' import { createBrowserUuid } from '@/lib/browser-uuid' import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id' +import { + getProviderSessionClaimKey, + isPassiveCompletedHibernationEvidence +} from '@/lib/sleeping-agent-pane-ownership' import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracker' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' @@ -676,8 +680,10 @@ type PanePtyBinding = IDisposable & { /** Navigation-free hibernation wake: fires the armed cold-restore --resume * without the size-reassert/foreground-sample side effects of a real reveal. * Used by the mobile wake fanout so a hidden hibernated pane resumes with no - * desktop hidden→visible transition. */ - wakeHibernatedAgentIfArmed: () => void + * desktop hidden→visible transition. Returns the sleeping record's provider + * session claim key when this pane started (or latched) the in-place wake, + * so the follow-up generic resume never launches the same session twice. */ + wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null /** Re-sample process identity when the pane gains intra-tab focus: the tab * icon follows the active leaf, and a shell-marked entry on a still-running * agent pane has no OSC boundary left to correct it. */ @@ -2079,35 +2085,80 @@ export function connectPanePty( let spawnedFreshPtyId: string | null = null // Why: hibernation suppresses its kill's exit while the pane is hidden, so // onExit must not tear the pane down — but the pane still owes the user a - // wake. Remember the hibernated ptyId; the visibility-resume hook consumes - // it and relaunches the recorded agent session on next reveal. - let hibernatedWakePtyId: string | null = null - let wakeHibernatedAgentPane: (() => void) | null = null + // wake. Remember the hibernated PTY and exact record; the visibility-resume + // hook consumes both and cannot accidentally adopt a later stale record. + type HibernatedWakeTarget = { ptyId: string; record: SleepingAgentSessionRecord } + let hibernatedWakeTarget: HibernatedWakeTarget | null = null + let wakeHibernatedAgentPane: (() => Promise) | null = null + // Why: a mobile wake can land after the sleeping record is written but + // before the suppressed kill exit arms the wake target. The phone never + // reveals the desktop pane, so without a latch the edge-triggered wake would + // be dropped and the phone left on a frozen terminal. + let pendingHibernatedWakeTarget: HibernatedWakeTarget | null = null + // Why: transport.connect settles asynchronously. Repeated mobile activation + // must keep claiming this provider session until the replacement PTY either + // exists (and clears the sleep record) or the spawn fails and can be retried. + let hibernatedWakeInFlightClaimKey: string | null = null // Why: reveal is the normal wake trigger, but a reveal that lands *during* the // in-flight hibernation kill runs noteVisibilityResume before onExit arms the // wake. Sharing the guarded consume lets both the reveal hook and the // arm-time foreground check resume the pane exactly once. - const consumeHibernatedAgentWake = (): void => { - if (hibernatedWakePtyId === null || disposed) { - return + const consumeHibernatedAgentWake = (claimedProviderSessions?: Set): string | null => { + const target = hibernatedWakeTarget + if (!target || disposed) { + return null } if (deps.paneTransportsRef.current.get(pane.id) !== transport) { - return + return null + } + const currentRecord = getSleepingRecordForPane(useAppStore.getState())?.record + if (currentRecord !== target.record) { + hibernatedWakeTarget = null + pendingHibernatedWakeTarget = null + return null } const currentPtyId = transport.getPtyId() // Why: a real pty:exit clears the transport's ptyId before onExit while a // reconcile-driven exit leaves it bound; both mean "nothing respawned since // hibernation". A different non-null id means another flow (e.g. an // intentional restart) already rebound the pane — its spawn wins. - if (currentPtyId !== null && currentPtyId !== hibernatedWakePtyId) { - hibernatedWakePtyId = null - return + if (currentPtyId !== null && currentPtyId !== target.ptyId) { + hibernatedWakeTarget = null + pendingHibernatedWakeTarget = null + return null } - hibernatedWakePtyId = null + if (!wakeHibernatedAgentPane) { + return null + } + const claimKey = getProviderSessionClaimKey(target.record) + if (claimedProviderSessions?.has(claimKey)) { + return null + } + // Why: one wake event can visit multiple mounted legacy/stable panes for + // the same provider session. Claim synchronously before any spawn starts. + claimedProviderSessions?.add(claimKey) + hibernatedWakeTarget = null + pendingHibernatedWakeTarget = null + hibernatedWakeInFlightClaimKey = claimKey // Why: reveal is the wake signal for a hibernated pane. Resume the recorded // agent session (or fall back to a fresh shell) instead of leaving the // frozen frame with no PTY behind it. - wakeHibernatedAgentPane?.() + void wakeHibernatedAgentPane() + .then((spawnedPtyId) => { + if (!spawnedPtyId) { + // Why: a transient replacement-spawn failure leaves the passive + // record owned by this pane. Re-arm the exact target so a later + // mobile open can retry instead of stranding the frozen session; + // consume revalidates disposal, binding, PTY, and record identity. + hibernatedWakeTarget = target + } + }) + .finally(() => { + if (hibernatedWakeInFlightClaimKey === claimKey) { + hibernatedWakeInFlightClaimKey = null + } + }) + return claimKey } const onExit = (ptyId: string): void => { if (handledExitPtyId === ptyId) { @@ -2153,20 +2204,34 @@ export function connectPanePty( // Why: the action that suppressed the exit owns whether the leaf binding // is a wake hint or should be discarded; runtime cleanup above is enough. manager.setPaneGpuRendering(pane.id, true) - if (getSleepingRecordForPane(useAppStore.getState())) { + const sleepingRecordEntry = getSleepingRecordForPane(useAppStore.getState()) + if ( + sleepingRecordEntry && + isPassiveCompletedHibernationEvidence(sleepingRecordEntry.record) + ) { // Why: hibernation killed this pane's PTY while hidden. The frozen TUI // frame still has mouse-tracking/bracketed-paste armed, which silently // eats every click and keystroke against a dead transport — disarm the // modes now and arm the reveal-time wake. replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_MODE_RESET) - hibernatedWakePtyId = ptyId - if (deps.isVisibleRef.current) { - // Why: a reveal that raced this kill already ran noteVisibilityResume - // before the exit landed, so it saw nothing armed. Consume the wake - // now (deferred off the exit handler) so a foreground pane still - // resumes without needing a second hide/reveal. - queueMicrotask(consumeHibernatedAgentWake) + hibernatedWakeTarget = { ptyId, record: sleepingRecordEntry.record } + const pendingWakeMatches = + pendingHibernatedWakeTarget?.ptyId === ptyId && + pendingHibernatedWakeTarget.record === sleepingRecordEntry.record + if (pendingHibernatedWakeTarget && !pendingWakeMatches) { + pendingHibernatedWakeTarget = null } + if (deps.isVisibleRef.current || pendingWakeMatches) { + // Why: a reveal (or a mobile wake) that raced this kill already ran + // before the exit landed, so it saw nothing armed. Consume the wake + // now (deferred off the exit handler) so the pane still resumes + // without needing a second hide/reveal or wake event. + queueMicrotask(() => { + consumeHibernatedAgentWake() + }) + } + } else if (pendingHibernatedWakeTarget?.ptyId === ptyId) { + pendingHibernatedWakeTarget = null } return } @@ -3669,9 +3734,9 @@ export function connectPanePty( const startFreshColdRestoreAgentResume = ( startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup(), options: FreshSpawnOptions = {} - ): void => { + ): Promise => { applyColdRestoreAgentResumeStartup(startup) - startFreshSpawn(startup, options) + return startFreshSpawn(startup, options) } // Why: the hibernation wake fires from noteVisibilityResume in the outer // connection scope, long after this deferred-connect closure has run. @@ -3751,7 +3816,7 @@ export function connectPanePty( const startFreshSpawn = ( startupOverride?: PendingStartupCommand | null, options: FreshSpawnOptions = {} - ): void => { + ): Promise => { clearPaneMode2031State() clearHiddenOutputRestoreState() prepareFreshShellViewportForSpawn(options) @@ -3880,6 +3945,7 @@ export function connectPanePty( // Why: split panes in the same tab can spawn concurrently. Key by pane // as well as tab so a remount cannot attach to a sibling setup pane's PTY. pendingSpawnByPaneKey.set(pendingSpawnKey, trackedPromise) + return trackedPromise } let foregroundRefreshRiskScanTail = '' @@ -6251,8 +6317,44 @@ export function connectPanePty( }, // Why: mobile wake reaches this pane while it stays hidden on the desktop, so // it must consume only the armed hibernation wake — no size/foreground reads. - wakeHibernatedAgentIfArmed() { - consumeHibernatedAgentWake() + wakeHibernatedAgentIfArmed(claimedProviderSessions) { + if (hibernatedWakeInFlightClaimKey) { + if (claimedProviderSessions?.has(hibernatedWakeInFlightClaimKey)) { + return null + } + claimedProviderSessions?.add(hibernatedWakeInFlightClaimKey) + return hibernatedWakeInFlightClaimKey + } + const consumedClaimKey = consumeHibernatedAgentWake(claimedProviderSessions) + if (consumedClaimKey) { + return consumedClaimKey + } + // Why: wake arrived mid-hibernation-kill — the record exists but onExit + // has not armed the wake target yet (the transport is still bound to the + // dying PTY). Only the exact PTY already marked for suppressed shutdown + // may latch; a stale/manual record beside an ordinary live PTY must not. + const state = useAppStore.getState() + const recordEntry = getSleepingRecordForPane(state) + const currentPtyId = transport.getPtyId() + if ( + recordEntry && + isPassiveCompletedHibernationEvidence(recordEntry.record) && + currentPtyId !== null && + state.suppressedPtyExitIds[currentPtyId] === true && + !disposed && + hibernatedWakeTarget === null && + deps.paneTransportsRef.current.get(pane.id) === transport && + transport.getPtyId() === currentPtyId + ) { + const claimKey = getProviderSessionClaimKey(recordEntry.record) + if (claimedProviderSessions?.has(claimKey)) { + return null + } + claimedProviderSessions?.add(claimKey) + pendingHibernatedWakeTarget = { ptyId: currentPtyId, record: recordEntry.record } + return claimKey + } + return null }, sampleForegroundAgentOnFocus() { sampleVisiblePaneForegroundAgent() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 4f9d93ed934..a91514856f1 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -1760,9 +1760,18 @@ export function useTerminalPaneLifecycle({ return } for (const panePtyBinding of panePtyBindingsRef.current.values()) { - ;( - panePtyBinding as IDisposable & { wakeHibernatedAgentIfArmed?: () => void } - ).wakeHibernatedAgentIfArmed?.() + const claimKey = ( + panePtyBinding as IDisposable & { + wakeHibernatedAgentIfArmed?: (claimedProviderSessions?: Set) => string | null + } + ).wakeHibernatedAgentIfArmed?.(detail.wokenClaimKeys) + // Why: the dispatcher's follow-up generic resume must skip provider + // sessions this pane woke (or latched) in place — the sleeping record + // is only cleared after the in-place spawn succeeds, so without this + // the same session would resume twice. + if (claimKey) { + detail.wokenClaimKeys?.add(claimKey) + } } } window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents) diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts index f2cef1eae04..9292eede164 100644 --- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts @@ -1,6 +1,48 @@ import { describe, expect, it, vi } from 'vitest' -import { addBackgroundMountedTerminalWorktree } from './background-terminal-worktree-mount' +import { + addBackgroundMountedTerminalWorktree, + applyBackgroundMountTabRestriction, + hasRequestedBackgroundTerminalWorktreeMount, + pruneClosedBackgroundMountTabs, + requestBackgroundTerminalWorktreeMount, + subscribeBackgroundTerminalWorktreeMountRequests, + takeAllPendingBackgroundTerminalWorktreeMounts, + shouldMountBackgroundWorktreeTab +} from './background-terminal-worktree-mount' + +describe('background terminal mount request registry', () => { + it('replays a request made before the Terminal listener mounts', () => { + takeAllPendingBackgroundTerminalWorktreeMounts() + const onRequest = vi.fn() + const unsubscribe = subscribeBackgroundTerminalWorktreeMountRequests(onRequest) + + requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-cold', tabIds: ['tab-1'] }) + + expect(hasRequestedBackgroundTerminalWorktreeMount()).toBe(true) + expect(onRequest).toHaveBeenCalledOnce() + expect(takeAllPendingBackgroundTerminalWorktreeMounts()).toEqual([ + { worktreeId: 'wt-cold', tabIds: ['tab-1'] } + ]) + onRequest.mockClear() + requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-later', tabIds: ['tab-2'] }) + expect(onRequest).not.toHaveBeenCalled() + unsubscribe() + }) + + it('unions targeted requests and lets a whole-worktree request dominate', () => { + takeAllPendingBackgroundTerminalWorktreeMounts() + requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-1', tabIds: ['tab-1'] }) + requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-1', tabIds: ['tab-2'] }) + expect(takeAllPendingBackgroundTerminalWorktreeMounts()).toEqual([ + { worktreeId: 'wt-1', tabIds: ['tab-1', 'tab-2'] } + ]) + + requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-1', tabIds: ['tab-1'] }) + requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-1' }) + expect(takeAllPendingBackgroundTerminalWorktreeMounts()).toEqual([{ worktreeId: 'wt-1' }]) + }) +}) describe('addBackgroundMountedTerminalWorktree', () => { it('adds a hidden worktree mount and notifies the caller once', () => { @@ -24,3 +66,79 @@ describe('addBackgroundMountedTerminalWorktree', () => { expect(onAdded).not.toHaveBeenCalled() }) }) + +describe('applyBackgroundMountTabRestriction', () => { + it('restricts a not-yet-mounted worktree to the targeted tabs and unions later targets', () => { + const restrictions = new Map>() + const mounted = new Set() + + applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-1', ['tab-1']) + expect(restrictions.get('wt-1')).toEqual(new Set(['tab-1'])) + + mounted.add('wt-1') + applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-1', ['tab-2', 'tab-1']) + expect(restrictions.get('wt-1')).toEqual(new Set(['tab-1', 'tab-2'])) + }) + + it('never narrows a fully mounted worktree retroactively', () => { + const restrictions = new Map>() + const mounted = new Set(['wt-visited']) + + applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-visited', ['tab-1']) + expect(restrictions.has('wt-visited')).toBe(false) + }) + + it('lifts the restriction on an untargeted (whole-worktree) mount', () => { + const restrictions = new Map>([['wt-1', new Set(['tab-1'])]]) + const mounted = new Set(['wt-1']) + + applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-1', undefined) + expect(restrictions.has('wt-1')).toBe(false) + }) + + it('keeps the existing set identity when the targets are already covered', () => { + const existing = new Set(['tab-1', 'tab-2']) + const restrictions = new Map>([['wt-1', existing]]) + const mounted = new Set(['wt-1']) + + applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-1', ['tab-2']) + expect(restrictions.get('wt-1')).toBe(existing) + }) +}) + +describe('shouldMountBackgroundWorktreeTab', () => { + it('mounts every tab without a restriction and only listed tabs with one', () => { + expect(shouldMountBackgroundWorktreeTab(null, 'tab-1')).toBe(true) + expect(shouldMountBackgroundWorktreeTab(new Set(['tab-1']), 'tab-1')).toBe(true) + expect(shouldMountBackgroundWorktreeTab(new Set(['tab-1']), 'tab-2')).toBe(false) + }) +}) + +describe('pruneClosedBackgroundMountTabs', () => { + it('retains live targets and releases the mount after the final target closes', () => { + const restrictions = new Map>([ + ['wt-1', new Set(['tab-1', 'tab-2'])] + ]) + const mounted = new Set(['wt-1']) + + expect( + pruneClosedBackgroundMountTabs(restrictions, mounted, { + 'wt-1': [{ id: 'tab-2' }, { id: 'unrelated-tab' }] + }) + ).toBe(true) + expect(restrictions.get('wt-1')).toEqual(new Set(['tab-2'])) + expect(mounted.has('wt-1')).toBe(true) + + expect(pruneClosedBackgroundMountTabs(restrictions, mounted, { 'wt-1': [] })).toBe(true) + expect(restrictions.has('wt-1')).toBe(false) + expect(mounted.has('wt-1')).toBe(false) + }) + + it('leaves unrestricted user-visited and whole-worktree mounts alone', () => { + const restrictions = new Map>() + const mounted = new Set(['wt-visited', 'wt-whole']) + + expect(pruneClosedBackgroundMountTabs(restrictions, mounted, {})).toBe(false) + expect(mounted).toEqual(new Set(['wt-visited', 'wt-whole'])) + }) +}) diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts index 3aff5faf530..af8bc370d8e 100644 --- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts @@ -1,3 +1,85 @@ +import { + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + type BackgroundMountTerminalWorktreeDetail +} from '@/constants/terminal' + +const pendingMounts = new Map() +const requestListeners = new Set<() => void>() +let hasRequestedMount = false + +function mergePendingMount(detail: BackgroundMountTerminalWorktreeDetail): void { + const existing = pendingMounts.get(detail.worktreeId) + if (!existing) { + pendingMounts.set(detail.worktreeId, { + worktreeId: detail.worktreeId, + ...(detail.tabIds !== undefined ? { tabIds: [...new Set(detail.tabIds)] } : {}) + }) + return + } + if (existing.tabIds === undefined || detail.tabIds === undefined) { + pendingMounts.set(detail.worktreeId, { worktreeId: detail.worktreeId }) + return + } + pendingMounts.set(detail.worktreeId, { + worktreeId: detail.worktreeId, + tabIds: [...new Set([...existing.tabIds, ...detail.tabIds])] + }) +} + +/** + * Records the request before emitting the compatibility event. Why: Terminal + * is lazy and absent on the landing screen, so a window-only event can fire + * before its listener exists and permanently lose a navigation-free wake. + */ +export function requestBackgroundTerminalWorktreeMount( + detail: BackgroundMountTerminalWorktreeDetail +): void { + if (!detail.worktreeId) { + return + } + mergePendingMount(detail) + if (!hasRequestedMount) { + hasRequestedMount = true + for (const listener of requestListeners) { + listener() + } + } + if (typeof window !== 'undefined') { + window.dispatchEvent( + new CustomEvent( + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + { detail } + ) + ) + } +} + +export function takePendingBackgroundTerminalWorktreeMount( + worktreeId: string | undefined +): BackgroundMountTerminalWorktreeDetail | null { + if (!worktreeId) { + return null + } + const pending = pendingMounts.get(worktreeId) ?? null + pendingMounts.delete(worktreeId) + return pending +} + +export function takeAllPendingBackgroundTerminalWorktreeMounts(): BackgroundMountTerminalWorktreeDetail[] { + const pending = [...pendingMounts.values()] + pendingMounts.clear() + return pending +} + +export function subscribeBackgroundTerminalWorktreeMountRequests(listener: () => void): () => void { + requestListeners.add(listener) + return () => requestListeners.delete(listener) +} + +export function hasRequestedBackgroundTerminalWorktreeMount(): boolean { + return hasRequestedMount +} + export function addBackgroundMountedTerminalWorktree( mountedWorktreeIds: Set, worktreeId: string | undefined, @@ -10,3 +92,69 @@ export function addBackgroundMountedTerminalWorktree( onAdded() return true } + +/** + * Records which terminal tabs a background mount may instantiate. Why: a + * whole-worktree background mount creates a TerminalPane (xterm + PTY connect) + * for every saved tab, so wake/resume flows pass the exact tabs they need. + * Must run before the worktree is added to `mountedWorktreeIds`. + */ +export function applyBackgroundMountTabRestriction( + restrictions: Map>, + mountedWorktreeIds: ReadonlySet, + worktreeId: string | undefined, + tabIds: readonly string[] | undefined +): void { + if (!worktreeId) { + return + } + const existing = restrictions.get(worktreeId) + // Why: a worktree mounted without a restriction is fully mounted (the user + // visited it, or a legacy whole-worktree mount ran); narrowing it + // retroactively would unmount live panes. + if (mountedWorktreeIds.has(worktreeId) && !existing) { + return + } + if (!tabIds) { + restrictions.delete(worktreeId) + return + } + if (existing && tabIds.every((tabId) => existing.has(tabId))) { + return + } + restrictions.set(worktreeId, new Set([...(existing ?? []), ...tabIds])) +} + +export function shouldMountBackgroundWorktreeTab( + restrictedTabIds: ReadonlySet | null, + tabId: string +): boolean { + return restrictedTabIds === null || restrictedTabIds.has(tabId) +} + +/** + * Releases targeted mounts after their owning tabs close. Whole-worktree and + * user-visited mounts have no restriction entry and are intentionally retained. + */ +export function pruneClosedBackgroundMountTabs( + restrictions: Map>, + mountedWorktreeIds: Set, + tabsByWorktree: Record +): boolean { + let changed = false + for (const [worktreeId, tabIds] of restrictions) { + const liveTabIds = new Set((tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) + const retained = new Set([...tabIds].filter((tabId) => liveTabIds.has(tabId))) + if (retained.size === tabIds.size) { + continue + } + changed = true + if (retained.size === 0) { + restrictions.delete(worktreeId) + mountedWorktreeIds.delete(worktreeId) + } else { + restrictions.set(worktreeId, retained) + } + } + return changed +} diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts index ed1a89aa634..1c0b5a5b713 100644 --- a/src/renderer/src/constants/terminal.ts +++ b/src/renderer/src/constants/terminal.ts @@ -74,8 +74,19 @@ export type CloseTerminalPaneDetail = { export type BackgroundMountTerminalWorktreeDetail = { worktreeId: string + /** When set, only these terminal tabs mount for the background worktree. + * Why: a worktree-wide background mount instantiates a TerminalPane (and + * its PTY connect work) for every saved tab; wake/resume flows know exactly + * which tabs they need. Omitted → whole-worktree mount (legacy dispatch + * sites); a real activation always lifts the restriction. */ + tabIds?: readonly string[] } export type WakeHibernatedAgentsWorktreeDetail = { worktreeId: string + /** Mutable collector: mounted panes that consume (or latch) the in-place + * hibernation wake add their provider-session claim keys here so the + * dispatcher's follow-up generic resume skips those sessions instead of + * launching them a second time. */ + wokenClaimKeys?: Set } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 3a7683073cc..1cebfdce6d6 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -2125,7 +2125,7 @@ describe('useIpcEvents updater integration', () => { expect(dispatchEvent).toHaveBeenCalledWith( expect.objectContaining({ type: 'orca-background-mount-terminal-worktree', - detail: { worktreeId: 'wt-3' } + detail: { worktreeId: 'wt-3', tabIds: ['tab-new'] } }) ) expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Shell', { @@ -2188,7 +2188,7 @@ describe('useIpcEvents updater integration', () => { expect(dispatchEvent).toHaveBeenCalledWith( expect.objectContaining({ type: 'orca-background-mount-terminal-worktree', - detail: { worktreeId: 'wt-2' } + detail: { worktreeId: 'wt-2', tabIds: ['tab-new'] } }) ) expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Codex', { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 5657d69a558..debbe00a545 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -9,13 +9,10 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow' import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' -import { wakeSleepingAgentsForWorktreeInBackground } from '@/lib/wake-sleeping-agents-in-background' +import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background' import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' -import { - BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, - SPLIT_TERMINAL_PANE_EVENT, - CLOSE_TERMINAL_PANE_EVENT -} from '@/constants/terminal' +import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' @@ -207,11 +204,7 @@ function acquireBrowserAutomationBootstrapLease( if (!targetWorktreeId) { return } - window.dispatchEvent( - new CustomEvent(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, { - detail: { worktreeId: targetWorktreeId } - }) - ) + requestBackgroundTerminalWorktreeMount({ worktreeId: targetWorktreeId }) let targetBrowserPageId = browserPageId ?? null if (!targetBrowserPageId) { const browserTabs = store.browserTabsByWorktree[targetWorktreeId] ?? [] @@ -836,6 +829,8 @@ function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): export function useIpcEvents(): void { useEffect(() => { const unsubs: (() => void)[] = [] + const backgroundSleepingAgentWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher() + unsubs.push(backgroundSleepingAgentWakeDispatcher.dispose) type PendingAgentStatusEvent = { data: AgentStatusIpcPayload firstSeenAt: number @@ -1661,14 +1656,6 @@ export function useIpcEvents(): void { const shouldSurfaceOwner = terminalPresentation !== 'background' if (shouldActivate) { activateTerminalInitiatedWorktree(store, worktreeId) - } else { - // Why: renderer-backed Codex startup must mount a TerminalPane so the - // PTY is born in the renderer, but it must not switch the active UI. - window.dispatchEvent( - new CustomEvent(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, { - detail: { worktreeId } - }) - ) } const tabOptions = data.launchAgent ? { @@ -1689,6 +1676,11 @@ export function useIpcEvents(): void { ...(data.cwd ? { startupCwd: data.cwd } : {}) } const tab = store.createTab(worktreeId, data.targetGroupId, undefined, tabOptions) + if (!shouldActivate) { + // Why: renderer-backed Codex startup must mount its new TerminalPane + // without switching UI or connecting every saved tab in the worktree. + requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) + } if (data.afterTabId) { const createdUnifiedTab = useAppStore .getState() @@ -1950,7 +1942,7 @@ export function useIpcEvents(): void { window.api.ui.onResumeSleepingAgents(({ worktreeId }) => { // Why: a phone opened this worktree; wake its slept agents on the host // renderer navigation-free (no desktop worktree/tab/view change). - wakeSleepingAgentsForWorktreeInBackground(worktreeId) + backgroundSleepingAgentWakeDispatcher.request(worktreeId) }) ) diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 954a1314ef9..2a089f8411c 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT } from '@/constants/terminal' import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' +import { resetRemoteRuntimeTerminalMultiplexersForTests } from '@/runtime/remote-runtime-terminal-multiplexer' const mockSpawn = vi.fn() +const mockKill = vi.fn() const mockWrite = vi.fn() const mockRuntimeEnvironmentCall = vi.fn() const mockRuntimeEnvironmentTransportCall = vi.fn() @@ -99,6 +101,7 @@ vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ describe('launchAgentBackgroundSession', () => { beforeEach(() => { + resetRemoteRuntimeTerminalMultiplexersForTests() clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() mockRuntimeEnvironmentTransportCall.mockImplementation( @@ -143,7 +146,8 @@ describe('launchAgentBackgroundSession', () => { api: { pty: { spawn: mockSpawn, - write: mockWrite + write: mockWrite, + kill: mockKill }, agentTrust: { markTrusted: mockMarkTrusted @@ -176,9 +180,12 @@ describe('launchAgentBackgroundSession', () => { expect(mockDispatchEvent).toHaveBeenCalledWith( expect.objectContaining({ type: BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, - detail: { worktreeId: 'wt-1' } + detail: { worktreeId: 'wt-1', tabIds: ['tab-1'] } }) ) + expect(mockUpdateTabPtyId.mock.invocationCallOrder[0]).toBeLessThan( + mockDispatchEvent.mock.invocationCallOrder[0] ?? 0 + ) expect(mockSpawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/repo/worktree', @@ -225,6 +232,33 @@ describe('launchAgentBackgroundSession', () => { expect(result).toMatchObject({ tabId: 'tab-1', paneKey, ptyId: 'pty-1' }) }) + it('does not mount the tab while the explicit PTY spawn is unresolved', async () => { + let resolveSpawn!: (result: { id: string }) => void + mockSpawn.mockReturnValueOnce( + new Promise<{ id: string }>((resolve) => { + resolveSpawn = resolve + }) + ) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + const launch = launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run slowly' + }) + await Promise.resolve() + + expect(mockCreateTab).toHaveBeenCalled() + expect(mockDispatchEvent).not.toHaveBeenCalled() + + resolveSpawn({ id: 'pty-slow' }) + await expect(launch).resolves.toMatchObject({ ptyId: 'pty-slow' }) + expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-slow') + expect(mockDispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ detail: { worktreeId: 'wt-1', tabIds: ['tab-1'] } }) + ) + }) + it('records effective launch config returned by local PTY spawn', async () => { const effectiveLaunchConfig = { agentCommand: "claude '--dangerously-skip-permissions'", @@ -627,4 +661,29 @@ describe('launchAgentBackgroundSession', () => { ptyId: 'remote:env-1@@terminal-1' }) }) + + it('closes a created runtime terminal when its data subscription fails', async () => { + state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' } + mockRuntimeEnvironmentSubscribe.mockRejectedValueOnce(new Error('subscription failed')) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await expect( + launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + ).rejects.toThrow('subscription failed') + + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.close', + params: { terminal: 'terminal-1' }, + timeoutMs: undefined + }) + expect(state.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') + expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith(expect.stringMatching(/^tab-1:/)) + expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { recordInteraction: false }) + expect(mockDispatchEvent).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 389c6972cf1..85a8f1171d7 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -1,5 +1,5 @@ import { useAppStore } from '@/store' -import { buildAgentStartupPlan, type AgentStartupPlan } from '@/lib/tui-agent-startup' +import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' import type { LaunchAgentBackgroundSessionArgs, LaunchAgentBackgroundSessionResult @@ -10,7 +10,7 @@ import { tuiAgentToAgentKind } from '@/lib/telemetry' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT } from '@/constants/terminal' +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -20,7 +20,8 @@ import { repoIsRemote } from '../../../shared/agent-launch-remote' import { makePaneKey } from '../../../shared/stable-pane-id' import { registerEagerPtyBuffer, - subscribeToPtyExit + subscribeToPtyExit, + type EagerPtyHandle } from '@/components/terminal-pane/pty-dispatcher' import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' @@ -29,7 +30,6 @@ import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { createBrowserUuid } from '@/lib/browser-uuid' import { - getRemoteRuntimeTerminalHandle, subscribeToRuntimeTerminalData, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' @@ -38,6 +38,14 @@ import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' import { createSshBackgroundStartupDelivery } from '@/lib/ssh-background-startup-delivery' import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' +function runBestEffortCleanup(action: () => void): void { + try { + action() + } catch { + // Preserve the launch/setup error that triggered cleanup. + } +} + export async function launchAgentBackgroundSession( args: LaunchAgentBackgroundSessionArgs ): Promise { @@ -75,46 +83,23 @@ export async function launchAgentBackgroundSession( const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' - let startupPlan: AgentStartupPlan | null = null - let pasteDraftAfterLaunch: string | null = null - if (hasPrompt && isFollowupPath) { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - agentArgs, - agentEnv, - platform: launchPlatform, - isRemote, - allowEmptyPromptLaunch: true - }) - pasteDraftAfterLaunch = trimmedPrompt - } else { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: hasPrompt ? trimmedPrompt : '', - cmdOverrides, - agentArgs, - agentEnv, - platform: launchPlatform, - isRemote, - allowEmptyPromptLaunch: !hasPrompt - }) - } + const pasteDraftAfterLaunch = hasPrompt && isFollowupPath ? trimmedPrompt : null + const startupPlan = buildAgentStartupPlan({ + agent, + prompt: hasPrompt && !isFollowupPath ? trimmedPrompt : '', + cmdOverrides, + agentArgs, + agentEnv, + platform: launchPlatform, + isRemote, + allowEmptyPromptLaunch: !hasPrompt || isFollowupPath + }) if (!startupPlan) { return null } // Why: automation runs should start without revealing the workspace. // Spawn the PTY immediately, then attach an inactive tab to the live session. - // Background-mount the hidden worktree first so its off-screen terminal surface - // gets a measurable layout box and the eager PTY buffer flushes on the first - // mount — mirroring the renderer-backed Codex startup path in useIpcEvents. - window.dispatchEvent( - new CustomEvent(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, { - detail: { worktreeId } - }) - ) const tab = store.createTab(worktreeId, undefined, undefined, { activate: false, recordInteraction: false @@ -161,6 +146,36 @@ export async function launchAgentBackgroundSession( getSettingsForWorktreeRuntimeOwner(store, worktreeId) ) let ptyId = '' + let runtimeTerminalHandle: string | null = null + let exitHandled = false + let eagerPtyBuffer: EagerPtyHandle | null = null + let unsubscribeExit = (): void => {}, + unsubscribeData = (): void => {} + const handleExit = (exitPtyId: string, code: number): void => { + if (exitHandled) { + return + } + exitHandled = true + unsubscribeExit() + unsubscribeData() + sshStartupDelivery.clear() + useAppStore.getState().clearTabPtyId(tab.id, exitPtyId) + useAppStore.getState().clearAgentLaunchConfig(paneKey) + onExit?.(exitPtyId, code) + } + const processAgentStatus = createAgentStatusOscProcessor() + const handleData = (data: string): void => { + data = sshStartupDelivery.handleData(data) + onData?.(data) + sshStartupDelivery.schedule(ptyId) + const processed = processAgentStatus(data) + for (const payload of processed.payloads) { + useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, { + launchToken + }) + onAgentStatus?.(payload) + } + } try { if (runtimeTarget.kind === 'environment') { // Why: runtime environments execute on the server; using local pty.spawn @@ -186,7 +201,8 @@ export async function launchAgentBackgroundSession( }, { timeoutMs: 15_000 } ) - ptyId = toRemoteRuntimePtyId(created.terminal.handle, runtimeTarget.environmentId) + runtimeTerminalHandle = created.terminal.handle + ptyId = toRemoteRuntimePtyId(runtimeTerminalHandle, runtimeTarget.environmentId) } else { const result = await window.api.pty.spawn({ cols: 120, @@ -220,93 +236,92 @@ export async function launchAgentBackgroundSession( }) } } + store.updateTabPtyId(tab.id, ptyId) + store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId)) + if (agent === 'command-code' && hasPrompt && !isFollowupPath) { + // Why: Command Code does not expose a prompt-start hook; seed working for + // hidden prompt launches so sidebar/activity surfaces do not stay idle. + store.setAgentStatus( + paneKey, + { + state: 'working', + prompt: trimmedPrompt, + agentType: agent + }, + undefined, + undefined, + undefined, + { launchConfig: startupPlan.launchConfig, launchToken } + ) + } + + if (runtimeTarget.kind === 'environment') { + if (!runtimeTerminalHandle) { + throw new Error('Runtime terminal id is invalid.') + } + unsubscribeData = await subscribeToRuntimeTerminalData( + store.settings, + ptyId, + `desktop:background:${tab.id}`, + handleData + ) + void callRuntimeRpc<{ wait: { exitCode?: number | null } }>( + runtimeTarget, + 'terminal.wait', + { terminal: runtimeTerminalHandle, for: 'exit' }, + { timeoutMs: 24 * 60 * 60 * 1000 } + ) + .then((result) => handleExit(ptyId, result.wait.exitCode ?? 0)) + .catch(() => {}) + } else { + eagerPtyBuffer = registerEagerPtyBuffer(ptyId, handleExit) + unsubscribeData = subscribeToPtyData(ptyId, handleData) + // Why: opening the workspace attaches a real terminal transport and disposes + // the eager exit handler. This sidecar keeps automation completion tracking + // alive regardless of whether the tab is hidden or mounted. + unsubscribeExit = subscribeToPtyExit(ptyId, (code) => handleExit(ptyId, code)) + } + + // Why: mount only after the explicit PTY is bound. Mounting at the earlier + // createTab boundary lets a slow SSH/remote spawn race TerminalPane's fresh + // spawn path and launch the agent twice. + requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) + + if (pasteDraftAfterLaunch !== null) { + void pasteDraftWhenAgentReady({ + tabId: tab.id, + content: pasteDraftAfterLaunch, + agent, + submit: true, + onTimeout: () => showAutomationPromptNotSentToast(agent) + }) + } + + return { tabId: tab.id, paneKey, ptyId, startupPlan } } catch (error) { - store.closeTab(tab.id, { recordInteraction: false }) + // Why: terminal creation and stream subscription are separate remote calls. + // A failure between them must not strand an invisible runtime terminal. + exitHandled = true + runBestEffortCleanup(unsubscribeExit) + runBestEffortCleanup(unsubscribeData) + runBestEffortCleanup(() => eagerPtyBuffer?.dispose()) + runBestEffortCleanup(() => sshStartupDelivery.clear()) + runBestEffortCleanup(() => store.clearTabPtyId(tab.id, ptyId)) + runBestEffortCleanup(() => store.clearAgentLaunchConfig(paneKey)) + if (ptyId) { + try { + if (runtimeTarget.kind === 'environment' && runtimeTerminalHandle) { + await callRuntimeRpc(runtimeTarget, 'terminal.close', { + terminal: runtimeTerminalHandle + }) + } else if (runtimeTarget.kind === 'local') { + await window.api.pty.kill(ptyId) + } + } catch { + // Best-effort close; retiring the invalid hidden tab must still proceed. + } + } + runBestEffortCleanup(() => store.closeTab(tab.id, { recordInteraction: false })) throw error } - store.updateTabPtyId(tab.id, ptyId) - store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId)) - if (agent === 'command-code' && hasPrompt && !isFollowupPath) { - // Why: Command Code does not expose a prompt-start hook; seed working for - // hidden prompt launches so sidebar/activity surfaces do not stay idle. - store.setAgentStatus( - paneKey, - { - state: 'working', - prompt: trimmedPrompt, - agentType: agent - }, - undefined, - undefined, - undefined, - { launchConfig: startupPlan.launchConfig, launchToken } - ) - } - let exitHandled = false - let unsubscribeExit = (): void => {}, - unsubscribeData = (): void => {} - const handleExit = (ptyId: string, code: number): void => { - if (exitHandled) { - return - } - exitHandled = true - unsubscribeExit() - unsubscribeData() - sshStartupDelivery.clear() - useAppStore.getState().clearTabPtyId(tab.id, ptyId) - useAppStore.getState().clearAgentLaunchConfig(paneKey) - onExit?.(ptyId, code) - } - const processAgentStatus = createAgentStatusOscProcessor() - const handleData = (data: string): void => { - data = sshStartupDelivery.handleData(data) - onData?.(data) - sshStartupDelivery.schedule(ptyId) - const processed = processAgentStatus(data) - for (const payload of processed.payloads) { - useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, { - launchToken - }) - onAgentStatus?.(payload) - } - } - if (runtimeTarget.kind === 'environment') { - unsubscribeData = await subscribeToRuntimeTerminalData( - store.settings, - ptyId, - `desktop:background:${tab.id}`, - handleData - ) - const terminal = getRemoteRuntimeTerminalHandle(ptyId) - if (!terminal) { - throw new Error('Runtime terminal id is invalid.') - } - void callRuntimeRpc<{ wait: { exitCode?: number | null } }>( - runtimeTarget, - 'terminal.wait', - { terminal, for: 'exit' }, - { timeoutMs: 24 * 60 * 60 * 1000 } - ) - .then((result) => handleExit(ptyId, result.wait.exitCode ?? 0)) - .catch(() => {}) - } else { - registerEagerPtyBuffer(ptyId, handleExit) - unsubscribeData = subscribeToPtyData(ptyId, handleData) - // Why: opening the workspace attaches a real terminal transport and disposes - // the eager exit handler. This sidecar keeps automation completion tracking - // alive regardless of whether the tab is hidden or mounted. - unsubscribeExit = subscribeToPtyExit(ptyId, (code) => handleExit(ptyId, code)) - } - - if (pasteDraftAfterLaunch !== null) { - void pasteDraftWhenAgentReady({ - tabId: tab.id, - content: pasteDraftAfterLaunch, - agent, - submit: true, - onTimeout: () => showAutomationPromptNotSentToast(agent) - }) - } - - return { tabId: tab.id, paneKey, ptyId, startupPlan } } diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts index bc5affb0c50..6fdec77f76c 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import { useAppStore } from '@/store' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' +import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership' const initialAppStoreState = useAppStore.getState() @@ -67,6 +68,58 @@ describe('resumeSleepingAgentSessionsForWorktree navigation suppression', () => expect(state.activeTabType).toBe('browser') }) + it('skips and preserves a record whose claim an in-place wake already consumed', () => { + // Regression (#7906): a mounted hibernated pane that consumed the mobile + // wake starts its own in-place --resume; the generic resume must neither + // launch a second tab for that provider session nor clear the record (the + // in-place spawn clears it on success). + const record = makeRecord({ origin: 'quit' }) + useAppStore.setState({ + activeWorktreeId: 'wt-other', + activeTabId: 'other-tab', + activeTabType: 'browser', + activeTabIdByWorktree: { 'wt-other': 'other-tab' }, + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1', { + suppressNavigation: true, + skipClaimKeys: new Set([getProviderSessionClaimKey(record)]) + }) + + expect(launched).toBe(0) + const state = useAppStore.getState() + expect(state.tabsByWorktree['wt-1']).toHaveLength(1) + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) + }) + + it('reports each launched resume tab through onSessionLaunched', () => { + const record = makeRecord({ origin: 'quit' }) + useAppStore.setState({ + activeWorktreeId: 'wt-other', + activeTabId: 'other-tab', + activeTabType: 'browser', + activeTabIdByWorktree: { 'wt-other': 'other-tab' }, + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launchedTabIds: string[] = [] + resumeSleepingAgentSessionsForWorktree('wt-1', { + suppressNavigation: true, + onSessionLaunched: (tabId) => launchedTabIds.push(tabId) + }) + + const resumedTab = useAppStore + .getState() + .tabsByWorktree['wt-1']?.find((tab) => tab.id !== 'tab-1') + // Why: the dispatcher background-mounts exactly these tabs — an + // activate:false tab otherwise never mounts and its startup never runs. + expect(resumedTab).toBeDefined() + expect(launchedTabIds).toEqual([resumedTab?.id]) + }) + it('still navigates to the resumed tab for default (desktop) callers', () => { // Regression guard: the suppress-navigation flag must be opt-in — desktop // resume keeps flipping the active view to the recovered terminal. diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index ba283ab370b..b73d4b02fe6 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -1,127 +1,20 @@ -import { toast } from 'sonner' import { useAppStore } from '@/store' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' -import { tuiAgentToAgentKind } from '@/lib/telemetry' -import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' -import { isWslUncPath } from '../../../shared/wsl-paths' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { - resolveTuiAgentLaunchArgs, - resolveTuiAgentLaunchEnv -} from '../../../shared/tui-agent-launch-defaults' import type { AgentProviderSessionMetadata, SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' -import { translate } from '@/i18n/i18n' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types' import { getProviderSessionClaimKey, isPassiveCompletedHibernationEvidence, recordPaneIsOwnedByPreservedPane } from './sleeping-agent-pane-ownership' +import { + launchSleepingAgentSession, + type ResumeSleepingAgentSessionsOptions +} from './sleeping-agent-session-launch' -function getResumeLaunchPlatform(worktreeId: string): NodeJS.Platform { - const state = useAppStore.getState() - const worktree = state.getKnownWorktreeById(worktreeId) - const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null - const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId) - if (projectRuntime?.status === 'repair-required') { - return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM - } - if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { - return 'linux' - } - if (repo?.connectionId || (worktree?.path && isWslUncPath(worktree.path))) { - return 'linux' - } - return CLIENT_PLATFORM -} - -function appendTabToWorktreeOrder(worktreeId: string, tabId: string): void { - const state = useAppStore.getState() - const termIds = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) - const editorIds = state.openFiles - .filter((file) => file.worktreeId === worktreeId) - .map((f) => f.id) - const browserIds = (state.browserTabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id) - const base = reconcileTabOrder( - state.tabBarOrderByWorktree[worktreeId], - termIds, - editorIds, - browserIds - ) - const order = base.filter((id) => id !== tabId) - order.push(tabId) - state.setTabBarOrder(worktreeId, order) -} - -// Why: mobile-driven wake runs on the desktop host renderer, so it must create -// the resume tab without stealing the desktop's active worktree/tab/view. -function launchSleepingAgentSession( - record: SleepingAgentSessionRecord, - options?: { suppressNavigation?: boolean } -): boolean { - const state = useAppStore.getState() - const launchConfig = record.launchConfig - const startupPlan = buildAgentResumeStartupPlan({ - agent: record.agent, - providerSession: record.providerSession, - cmdOverrides: state.settings?.agentCmdOverrides ?? {}, - agentArgs: - launchConfig !== undefined - ? launchConfig.agentArgs - : resolveTuiAgentLaunchArgs(record.agent, state.settings?.agentDefaultArgs), - agentEnv: - launchConfig !== undefined - ? launchConfig.agentEnv - : resolveTuiAgentLaunchEnv(record.agent, state.settings?.agentDefaultEnv), - ...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}), - platform: getResumeLaunchPlatform(record.worktreeId) - }) - if (!startupPlan) { - toast.error( - translate( - 'auto.lib.resume.sleeping.agent.session.f235f604fd', - 'This agent session cannot be resumed.' - ) - ) - return false - } - - const tab = state.createTab(record.worktreeId, undefined, undefined, { - launchAgent: record.agent, - ...(options?.suppressNavigation ? { activate: false, recordInteraction: false } : {}) - }) - state.queueTabStartupCommand(tab.id, { - command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, - resumeProviderSession: record.providerSession, - launchAgent: record.agent, - ...(startupPlan.startupCommandDelivery - ? { startupCommandDelivery: startupPlan.startupCommandDelivery } - : {}), - showSessionRestoredBanner: true, - telemetry: { - agent_kind: tuiAgentToAgentKind(record.agent), - launch_source: 'sidebar', - request_kind: 'resume' - } - }) - state.claimAutomaticAgentResume(tab.id, { - worktreeId: record.worktreeId, - launchAgent: record.agent, - providerSession: record.providerSession - }) - state.clearSleepingAgentSession(record.paneKey) - if (!options?.suppressNavigation) { - state.setActiveTabType('terminal') - } - appendTabToWorktreeOrder(record.worktreeId, tab.id) - return true -} +export type { ResumeSleepingAgentSessionsOptions } from './sleeping-agent-session-launch' function clearPassiveCompletedRecordsForClaimKey( records: readonly SleepingAgentSessionRecord[], @@ -249,7 +142,7 @@ function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord): export function resumeSleepingAgentSessionsForWorktree( worktreeId: string, - options?: { suppressNavigation?: boolean } + options?: ResumeSleepingAgentSessionsOptions ): number { const state = useAppStore.getState() const worktreeRecords = Object.values(state.sleepingAgentSessionsByPaneKey) @@ -272,6 +165,12 @@ export function resumeSleepingAgentSessionsForWorktree( continue } const claimKey = getProviderSessionClaimKey(record) + // Why: a mounted pane already consumed (or latched) the in-place + // hibernation wake for this session; its record clears when that spawn + // succeeds. Launching or clearing here would double-resume the session. + if (options?.skipClaimKeys?.has(claimKey)) { + continue + } if (isInvalidWorktreeActivationRecord(record)) { state.clearSleepingAgentSession(record.paneKey) continue diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts new file mode 100644 index 00000000000..fea4c3d5de8 --- /dev/null +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -0,0 +1,128 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' +import { tuiAgentToAgentKind } from '@/lib/telemetry' +import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' +import { isWslUncPath } from '../../../shared/wsl-paths' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../../shared/tui-agent-launch-defaults' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { translate } from '@/i18n/i18n' + +export type ResumeSleepingAgentSessionsOptions = { + suppressNavigation?: boolean + /** Provider-session claim keys already woken in place by mounted panes + * (WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT). Their sleeping records are + * cleared only after the in-place spawn succeeds, so the generic resume + * must neither launch nor clear them here. */ + skipClaimKeys?: ReadonlySet + /** Called with the tab id of each freshly launched resume tab, so + * navigation-suppressed callers can background-mount exactly those tabs. */ + onSessionLaunched?: (tabId: string) => void +} + +function getResumeLaunchPlatform(worktreeId: string): NodeJS.Platform { + const state = useAppStore.getState() + const worktree = state.getKnownWorktreeById(worktreeId) + const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null + const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId) + if (projectRuntime?.status === 'repair-required') { + return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM + } + if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { + return 'linux' + } + if (repo?.connectionId || (worktree?.path && isWslUncPath(worktree.path))) { + return 'linux' + } + return CLIENT_PLATFORM +} + +function appendTabToWorktreeOrder(worktreeId: string, tabId: string): void { + const state = useAppStore.getState() + const termIds = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) + const editorIds = state.openFiles + .filter((file) => file.worktreeId === worktreeId) + .map((f) => f.id) + const browserIds = (state.browserTabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id) + const base = reconcileTabOrder( + state.tabBarOrderByWorktree[worktreeId], + termIds, + editorIds, + browserIds + ) + const order = base.filter((id) => id !== tabId) + order.push(tabId) + state.setTabBarOrder(worktreeId, order) +} + +// Why: mobile-driven wake runs on the desktop host renderer, so it must create +// the resume tab without stealing the desktop's active worktree/tab/view. +export function launchSleepingAgentSession( + record: SleepingAgentSessionRecord, + options?: ResumeSleepingAgentSessionsOptions +): boolean { + const state = useAppStore.getState() + const launchConfig = record.launchConfig + const startupPlan = buildAgentResumeStartupPlan({ + agent: record.agent, + providerSession: record.providerSession, + cmdOverrides: state.settings?.agentCmdOverrides ?? {}, + agentArgs: + launchConfig !== undefined + ? launchConfig.agentArgs + : resolveTuiAgentLaunchArgs(record.agent, state.settings?.agentDefaultArgs), + agentEnv: + launchConfig !== undefined + ? launchConfig.agentEnv + : resolveTuiAgentLaunchEnv(record.agent, state.settings?.agentDefaultEnv), + ...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}), + platform: getResumeLaunchPlatform(record.worktreeId) + }) + if (!startupPlan) { + toast.error( + translate( + 'auto.lib.resume.sleeping.agent.session.f235f604fd', + 'This agent session cannot be resumed.' + ) + ) + return false + } + + const tab = state.createTab(record.worktreeId, undefined, undefined, { + launchAgent: record.agent, + ...(options?.suppressNavigation ? { activate: false, recordInteraction: false } : {}) + }) + state.queueTabStartupCommand(tab.id, { + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig, + resumeProviderSession: record.providerSession, + launchAgent: record.agent, + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), + showSessionRestoredBanner: true, + telemetry: { + agent_kind: tuiAgentToAgentKind(record.agent), + launch_source: 'sidebar', + request_kind: 'resume' + } + }) + state.claimAutomaticAgentResume(tab.id, { + worktreeId: record.worktreeId, + launchAgent: record.agent, + providerSession: record.providerSession + }) + state.clearSleepingAgentSession(record.paneKey) + if (!options?.suppressNavigation) { + state.setActiveTabType('terminal') + } + appendTabToWorktreeOrder(record.worktreeId, tab.id) + options?.onSessionLaunched?.(tab.id) + return true +} diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts index 4fbe281dfbc..67b82dbbb11 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts @@ -3,43 +3,85 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, - WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT + WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, + type BackgroundMountTerminalWorktreeDetail, + type WakeHibernatedAgentsWorktreeDetail } from '@/constants/terminal' +import type { ResumeSleepingAgentSessionsOptions } from './resume-sleeping-agent-session' -const resumeSpy = vi.fn() +const resumeSpy = vi.fn< + (worktreeId: string, options?: ResumeSleepingAgentSessionsOptions) => number +>(() => 0) vi.mock('./resume-sleeping-agent-session', () => ({ - resumeSleepingAgentSessionsForWorktree: (worktreeId: string, options?: unknown) => - resumeSpy(worktreeId, options) + resumeSleepingAgentSessionsForWorktree: ( + worktreeId: string, + options?: ResumeSleepingAgentSessionsOptions + ) => resumeSpy(worktreeId, options) })) // Why: control passive-vs-non-passive classification directly so the test asserts // the gating, not the predicate internals. const isPassiveSpy = vi.fn() vi.mock('./sleeping-agent-pane-ownership', () => ({ - isPassiveCompletedHibernationEvidence: (record: unknown) => isPassiveSpy(record) + isPassiveCompletedHibernationEvidence: (record: unknown) => isPassiveSpy(record), + recordPaneIsOwnedByPreservedPane: () => false, + getProviderSessionClaimKey: (record: { + worktreeId: string + agent?: string + providerSession?: { key?: string; id?: string } + }) => + `${record.worktreeId}\0${record.agent ?? 'agent'}\0${record.providerSession?.key ?? 'session_id'}\0${record.providerSession?.id ?? 'session'}` })) -let sleepingRecords: Record = {} +let sleepingRecords: Record = {} +let terminalTabsByWorktree: Record = {} +const clearSleepingAgentSessionsByPaneKey = vi.fn((paneKeys: readonly string[]) => { + for (const paneKey of paneKeys) { + delete sleepingRecords[paneKey] + } +}) vi.mock('@/store', () => ({ useAppStore: { - getState: () => ({ sleepingAgentSessionsByPaneKey: sleepingRecords }) + getState: () => ({ + sleepingAgentSessionsByPaneKey: sleepingRecords, + tabsByWorktree: terminalTabsByWorktree, + clearSleepingAgentSessionsByPaneKey + }) } })) -import { wakeSleepingAgentsForWorktreeInBackground } from './wake-sleeping-agents-in-background' +import { + createBackgroundSleepingAgentWakeDispatcher, + wakeSleepingAgentsForWorktreeInBackground +} from './wake-sleeping-agents-in-background' -function recordEvents(): { events: string[]; stop: () => void } { +type RecordedEvents = { + events: string[] + mountDetails: BackgroundMountTerminalWorktreeDetail[] + wakeDetails: WakeHibernatedAgentsWorktreeDetail[] + stop: () => void +} + +function recordEvents(): RecordedEvents { const events: string[] = [] + const mountDetails: BackgroundMountTerminalWorktreeDetail[] = [] + const wakeDetails: WakeHibernatedAgentsWorktreeDetail[] = [] const onWake = (event: Event): void => { - events.push(`wake:${(event as CustomEvent<{ worktreeId: string }>).detail.worktreeId}`) + const detail = (event as CustomEvent).detail + events.push(`wake:${detail.worktreeId}`) + wakeDetails.push(detail) } const onMount = (event: Event): void => { - events.push(`mount:${(event as CustomEvent<{ worktreeId: string }>).detail.worktreeId}`) + const detail = (event as CustomEvent).detail + events.push(`mount:${detail.worktreeId}`) + mountDetails.push(detail) } window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) window.addEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount) return { events, + mountDetails, + wakeDetails, stop: () => { window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) window.removeEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount) @@ -49,17 +91,52 @@ function recordEvents(): { events: string[]; stop: () => void } { beforeEach(() => { sleepingRecords = {} + terminalTabsByWorktree = {} + clearSleepingAgentSessionsByPaneKey.mockClear() isPassiveSpy.mockReset() resumeSpy.mockReset() + resumeSpy.mockReturnValue(0) }) afterEach(() => { resumeSpy.mockReset() }) +describe('createBackgroundSleepingAgentWakeDispatcher', () => { + it('deduplicates early wakes and drains them once workspace hydration completes', () => { + let workspaceSessionReady = false + let readinessListener: (() => void) | null = null + const unsubscribe = vi.fn() + const wake = vi.fn() + const dispatcher = createBackgroundSleepingAgentWakeDispatcher({ + isWorkspaceSessionReady: () => workspaceSessionReady, + subscribeToStore: (listener) => { + readinessListener = listener + return unsubscribe + }, + wake + }) + + dispatcher.request('wt-cold') + dispatcher.request('wt-cold') + expect(wake).not.toHaveBeenCalled() + + workspaceSessionReady = true + ;(readinessListener as unknown as () => void)() + + expect(unsubscribe).toHaveBeenCalledOnce() + expect(wake).toHaveBeenCalledOnce() + expect(wake).toHaveBeenCalledWith('wt-cold') + + dispatcher.request('wt-ready') + expect(wake).toHaveBeenLastCalledWith('wt-ready') + dispatcher.dispose() + }) +}) + describe('wakeSleepingAgentsForWorktreeInBackground', () => { - it('fires wake, background-mount, then resume when a passive record exists', () => { - sleepingRecords = { k1: { worktreeId: 'wt-1' } } + it('fires wake, targeted background-mount, then resume when a passive record exists', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1', paneKey: 'tab-a:leaf-1', tabId: 'tab-a' } } isPassiveSpy.mockReturnValue(true) const rec = recordEvents() @@ -69,26 +146,166 @@ describe('wakeSleepingAgentsForWorktreeInBackground', () => { // (a) pane-level wake of mounted hidden panes fires before (b) background-mount // of not-yet-mounted panes. expect(rec.events).toEqual(['wake:wt-1', 'mount:wt-1']) + // Why: the mount targets only the sleeping record's tab, so one slept agent + // does not permanently mount every saved tab in the worktree. + expect(rec.mountDetails[0]?.tabIds).toEqual(['tab-a']) // (c) non-passive records resume with navigation suppressed (INV-2). - expect(resumeSpy).toHaveBeenCalledWith('wt-1', { suppressNavigation: true }) + expect(resumeSpy).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ suppressNavigation: true }) + ) }) - it('skips background-mount when only non-passive records exist', () => { - sleepingRecords = { k1: { worktreeId: 'wt-1' } } + it('falls back to a whole-worktree mount when a passive record has no resolvable tab', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1', paneKey: 'not-a-pane-key' } } + isPassiveSpy.mockReturnValue(true) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + expect(rec.events).toEqual(['wake:wt-1', 'mount:wt-1']) + expect(rec.mountDetails[0]?.tabIds).toBeUndefined() + }) + + it('mounts one canonical tab and clears cold aliases for the same provider session', () => { + sleepingRecords = { + 'tab-a:leaf-1': { + worktreeId: 'wt-1', + paneKey: 'tab-a:leaf-1', + tabId: 'tab-a', + capturedAt: 1, + updatedAt: 1 + } as never, + 'tab-b:leaf-1': { + worktreeId: 'wt-1', + paneKey: 'tab-b:leaf-1', + tabId: 'tab-b', + capturedAt: 2, + updatedAt: 2 + } as never + } + isPassiveSpy.mockReturnValue(true) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + expect(rec.mountDetails).toEqual([{ worktreeId: 'wt-1', tabIds: ['tab-a'] }]) + expect(clearSleepingAgentSessionsByPaneKey).toHaveBeenCalledWith(['tab-b:leaf-1']) + expect(sleepingRecords).toHaveProperty('tab-a:leaf-1') + expect(sleepingRecords).not.toHaveProperty('tab-b:leaf-1') + }) + + it('prefers a live duplicate tab when the oldest alias tab is gone', () => { + sleepingRecords = { + 'missing-tab:leaf-1': { + worktreeId: 'wt-1', + paneKey: 'missing-tab:leaf-1', + tabId: 'missing-tab', + capturedAt: 1, + updatedAt: 1 + } as never, + 'live-tab:leaf-1': { + worktreeId: 'wt-1', + paneKey: 'live-tab:leaf-1', + tabId: 'live-tab', + capturedAt: 2, + updatedAt: 2 + } as never + } + terminalTabsByWorktree = { 'wt-1': [{ id: 'live-tab' }] } + isPassiveSpy.mockReturnValue(true) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + expect(rec.mountDetails).toEqual([{ worktreeId: 'wt-1', tabIds: ['live-tab'] }]) + expect(clearSleepingAgentSessionsByPaneKey).toHaveBeenCalledWith(['missing-tab:leaf-1']) + }) + + it('clears many cold aliases in one store action', () => { + sleepingRecords = Object.fromEntries( + Array.from({ length: 100 }, (_, index) => { + const tabId = `tab-${index}` + return [ + `${tabId}:leaf-1`, + { + worktreeId: 'wt-1', + paneKey: `${tabId}:leaf-1`, + tabId, + capturedAt: index, + updatedAt: index + } as never + ] + }) + ) + isPassiveSpy.mockReturnValue(true) + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + expect(clearSleepingAgentSessionsByPaneKey).toHaveBeenCalledOnce() + expect(clearSleepingAgentSessionsByPaneKey.mock.calls[0]?.[0]).toHaveLength(99) + expect(Object.keys(sleepingRecords)).toEqual(['tab-0:leaf-1']) + }) + + it('background-mounts the tabs the suppressed resume launches for non-passive records', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1', paneKey: 'tab-a:leaf-1', tabId: 'tab-a' } } + isPassiveSpy.mockReturnValue(false) + resumeSpy.mockImplementation((_worktreeId, options) => { + options?.onSessionLaunched?.('tab-new') + return 1 + }) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + // Why: the resume tab is created with activate:false, so nothing else + // mounts it — without this mount the queued --resume never gets a PTY. + expect(rec.events).toEqual(['wake:wt-1', 'mount:wt-1']) + expect(rec.mountDetails[0]?.tabIds).toEqual(['tab-new']) + }) + + it('skips background-mount when only non-passive records exist and nothing launches', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1', paneKey: 'tab-a:leaf-1', tabId: 'tab-a' } } isPassiveSpy.mockReturnValue(false) const rec = recordEvents() wakeSleepingAgentsForWorktreeInBackground('wt-1') rec.stop() - // Why: no passive record → no not-yet-mounted pane to fresh-connect, so - // background-mount must not run (it would strand a plain shell / mount work). + // Why: no passive record and no launched resume tab → nothing needs a + // mount (it would strand a plain shell / mount work). expect(rec.events).toEqual(['wake:wt-1']) - expect(resumeSpy).toHaveBeenCalledWith('wt-1', { suppressNavigation: true }) + expect(resumeSpy).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ suppressNavigation: true }) + ) + }) + + it('passes claims consumed by mounted panes to the generic resume as skipClaimKeys', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1', paneKey: 'tab-a:leaf-1', tabId: 'tab-a' } } + isPassiveSpy.mockReturnValue(false) + // A mounted pane consuming the in-place wake adds its claim key to the + // event detail — exactly what use-terminal-pane-lifecycle does. + const onWake = (event: Event): void => { + ;(event as CustomEvent).detail.wokenClaimKeys?.add( + 'claim-1' + ) + } + window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) + const options = resumeSpy.mock.calls[0]?.[1] + expect(options?.skipClaimKeys?.has('claim-1')).toBe(true) }) it('does nothing when the worktree has no sleeping records', () => { - sleepingRecords = { k1: { worktreeId: 'other-wt' } } + sleepingRecords = { k1: { worktreeId: 'other-wt', paneKey: 'tab-a:leaf-1' } } const rec = recordEvents() wakeSleepingAgentsForWorktreeInBackground('wt-1') diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts index 05369adbfe1..d2543fc1a8e 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts @@ -1,25 +1,164 @@ import { - BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, - type BackgroundMountTerminalWorktreeDetail, type WakeHibernatedAgentsWorktreeDetail } from '@/constants/terminal' +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { useAppStore } from '@/store' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' -import { isPassiveCompletedHibernationEvidence } from './sleeping-agent-pane-ownership' +import { + getProviderSessionClaimKey, + isPassiveCompletedHibernationEvidence, + recordPaneIsOwnedByPreservedPane +} from './sleeping-agent-pane-ownership' + +type BackgroundSleepingAgentWakeDispatcherOptions = { + isWorkspaceSessionReady?: () => boolean + subscribeToStore?: (listener: () => void) => () => void + wake?: (worktreeId: string) => void +} + +/** + * Buffers main's one-shot mobile wake until persisted sleeping records exist. + * Why: the renderer can attach its IPC listener before workspace hydration; + * dropping an early event leaves the phone connected to frozen slept panes. + */ +export function createBackgroundSleepingAgentWakeDispatcher( + options: BackgroundSleepingAgentWakeDispatcherOptions = {} +): { request: (worktreeId: string) => void; dispose: () => void } { + const pendingWorktreeIds = new Set() + const isWorkspaceSessionReady = + options.isWorkspaceSessionReady ?? (() => useAppStore.getState().workspaceSessionReady) + const subscribeToStore = + options.subscribeToStore ?? ((listener) => useAppStore.subscribe(listener)) + const wake = options.wake ?? wakeSleepingAgentsForWorktreeInBackground + let unsubscribeReadiness: (() => void) | null = null + let disposed = false + + const flushWhenReady = (): void => { + if (disposed || !isWorkspaceSessionReady()) { + return + } + const worktreeIds = [...pendingWorktreeIds] + pendingWorktreeIds.clear() + unsubscribeReadiness?.() + unsubscribeReadiness = null + for (const worktreeId of worktreeIds) { + wake(worktreeId) + } + } + + return { + request(worktreeId) { + if (disposed || !worktreeId) { + return + } + if (isWorkspaceSessionReady()) { + wake(worktreeId) + return + } + pendingWorktreeIds.add(worktreeId) + unsubscribeReadiness ??= subscribeToStore(flushWhenReady) + }, + dispose() { + disposed = true + pendingWorktreeIds.clear() + unsubscribeReadiness?.() + unsubscribeReadiness = null + } + } +} + +function getSleepingRecordTabId(record: SleepingAgentSessionRecord): string | null { + return ( + record.tabId ?? + parsePaneKey(record.paneKey)?.tabId ?? + parseLegacyNumericPaneKey(record.paneKey)?.tabId ?? + null + ) +} + +function dispatchBackgroundMount(worktreeId: string, tabIds: readonly string[] | undefined): void { + requestBackgroundTerminalWorktreeMount({ worktreeId, ...(tabIds ? { tabIds } : {}) }) +} + +function getCanonicalPassiveWakeRecords( + records: readonly SleepingAgentSessionRecord[], + alreadyClaimed: ReadonlySet +): SleepingAgentSessionRecord[] { + const activeClaimKeys = new Set( + records + .filter((record) => !isPassiveCompletedHibernationEvidence(record)) + .map(getProviderSessionClaimKey) + ) + const recordsByClaim = new Map() + for (const record of records) { + if (!isPassiveCompletedHibernationEvidence(record)) { + continue + } + const claimKey = getProviderSessionClaimKey(record) + if (alreadyClaimed.has(claimKey) || activeClaimKeys.has(claimKey)) { + continue + } + const grouped = recordsByClaim.get(claimKey) ?? [] + grouped.push(record) + recordsByClaim.set(claimKey, grouped) + } + + const canonicalRecords: SleepingAgentSessionRecord[] = [] + const duplicatePaneKeys: string[] = [] + const state = useAppStore.getState() + for (const grouped of recordsByClaim.values()) { + const ordered = grouped + .slice() + .sort((a, b) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt) + const liveTabIds = new Set( + (state.tabsByWorktree[grouped[0]?.worktreeId ?? ''] ?? []).map((tab) => tab.id) + ) + const canonical = + ordered.find((record) => recordPaneIsOwnedByPreservedPane(record, state)) ?? + ordered.find((record) => { + const tabId = getSleepingRecordTabId(record) + return tabId !== null && liveTabIds.has(tabId) + }) ?? + ordered.find((record) => getSleepingRecordTabId(record) !== null) ?? + ordered[0] + if (!canonical) { + continue + } + canonicalRecords.push(canonical) + for (const duplicate of grouped) { + if (duplicate !== canonical) { + // Why: two cold panes mount after the event-scoped claim collector is + // gone. Keep one provider-session record so only one can issue resume. + duplicatePaneKeys.push(duplicate.paneKey) + } + } + } + state.clearSleepingAgentSessionsByPaneKey(duplicatePaneKeys) + return canonicalRecords +} /** * Wakes a worktree's slept agents on the desktop host renderer with NO desktop * navigation — used when a phone (`clientKind: 'mobile'`) opens the worktree. - * Runs up to three steps, in order: + * Runs up to four steps, in order: * (a) fire the armed cold-restore `--resume` of the worktree's mounted hidden * hibernated panes (the experimental agent-sleep records; the primary - * wake mechanism, since those records are passive for path C); - * (b) background-mount so a hibernated pane that is NOT currently mounted - * (post-restart / evicted) mounts offscreen and takes the fresh-connect - * cold-restore path; + * wake mechanism, since those records are passive for path C). Panes that + * consume — or latch, when the wake races the hibernation kill — the + * in-place wake claim their provider sessions via the event detail; + * (b) background-mount the tabs holding passive hibernated records that are + * NOT currently mounted (post-restart / evicted) so they take the + * fresh-connect cold-restore path. The mount is targeted by tabId so one + * sleeping pane does not permanently mount every saved tab; * (c) resume the non-passive record classes (manual sleep of a still-working - * agent, `origin: 'quit'`) with navigation suppressed. + * agent, `origin: 'quit'`) with navigation suppressed, skipping the + * claims from (a); + * (d) background-mount the tabs (c) created — they are `activate: false`, so + * nothing else would mount them and their queued `--resume` startup + * would otherwise never reach a PTY. * Woken PTYs auto-publish to mobile via the renderer graph republish, so no * spawn is awaited. */ @@ -34,24 +173,40 @@ export function wakeSleepingAgentsForWorktreeInBackground(worktreeId: string): v return } + const wokenClaimKeys = new Set() window.dispatchEvent( new CustomEvent(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, { - detail: { worktreeId } + detail: { worktreeId, wokenClaimKeys } }) ) // Why: only a passive completed-hibernation record has a not-yet-mounted pane - // that needs a fresh-connect cold-restore (step b). Gating on it avoids mounting - // the worktree for non-passive records — which step (c) recovers into a fresh - // tab — so background-mount can't strand a plain shell in the stale tab. - if (worktreeRecords.some(isPassiveCompletedHibernationEvidence)) { - window.dispatchEvent( - new CustomEvent( - BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, - { - detail: { worktreeId } - } - ) + // that needs a fresh-connect cold-restore (step b). Non-passive records are + // recovered by step (c) into a fresh tab, mounted in step (d). + const passiveTabIds = new Set() + let hasUntargetablePassiveRecord = false + for (const record of getCanonicalPassiveWakeRecords(worktreeRecords, wokenClaimKeys)) { + const tabId = getSleepingRecordTabId(record) + if (tabId) { + passiveTabIds.add(tabId) + } else { + hasUntargetablePassiveRecord = true + } + } + if (passiveTabIds.size > 0 || hasUntargetablePassiveRecord) { + // Why: a record whose tab cannot be resolved falls back to the untargeted + // whole-worktree mount rather than silently never waking. + dispatchBackgroundMount( + worktreeId, + hasUntargetablePassiveRecord ? undefined : [...passiveTabIds] ) } - resumeSleepingAgentSessionsForWorktree(worktreeId, { suppressNavigation: true }) + const launchedTabIds: string[] = [] + resumeSleepingAgentSessionsForWorktree(worktreeId, { + suppressNavigation: true, + skipClaimKeys: wokenClaimKeys, + onSessionLaunched: (tabId) => launchedTabIds.push(tabId) + }) + if (launchedTabIds.length > 0) { + dispatchBackgroundMount(worktreeId, launchedTabIds) + } } diff --git a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts index 271fc9495b9..b773a0d7721 100644 --- a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts +++ b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts @@ -462,6 +462,55 @@ describe('captureAllSleepingAgentSessions', () => { }) }) + it('clears multiple sleeping records and launch configs in one update', () => { + const store = createTestStore() + const sleeping = { + 'tab-1:leaf-1': { paneKey: 'tab-1:leaf-1' }, + 'tab-2:leaf-2': { paneKey: 'tab-2:leaf-2' }, + 'tab-3:leaf-3': { paneKey: 'tab-3:leaf-3' } + } as unknown as AppState['sleepingAgentSessionsByPaneKey'] + const launchConfigs = { + 'tab-1:leaf-1': { + launchConfig: { agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: {} + }, + 'tab-2:leaf-2': { + launchConfig: { agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: {} + }, + 'tab-3:leaf-3': { + launchConfig: { agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: {} + } + } as AppState['agentLaunchConfigByPaneKey'] + store.setState({ + sleepingAgentSessionsByPaneKey: sleeping, + agentLaunchConfigByPaneKey: launchConfigs + }) + let changedUpdates = 0 + const unsubscribe = store.subscribe((next, previous) => { + if ( + next.sleepingAgentSessionsByPaneKey !== previous.sleepingAgentSessionsByPaneKey || + next.agentLaunchConfigByPaneKey !== previous.agentLaunchConfigByPaneKey + ) { + changedUpdates += 1 + } + }) + + store + .getState() + .clearSleepingAgentSessionsByPaneKey(['tab-1:leaf-1', 'tab-2:leaf-2', 'tab-2:leaf-2']) + unsubscribe() + + const state = store.getState() + expect(changedUpdates).toBe(1) + expect(Object.keys(state.sleepingAgentSessionsByPaneKey)).toEqual(['tab-3:leaf-3']) + expect(Object.keys(state.agentLaunchConfigByPaneKey)).toEqual(['tab-3:leaf-3']) + }) + it('clears launch config registry entries when invalid sleeping sessions are pruned', () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 57cddf6a8f2..06b611a8b7f 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -192,6 +192,7 @@ export type AgentStatusSlice = { * quit flush so provider session ids survive an app restart. */ captureAllSleepingAgentSessions: () => void clearSleepingAgentSession: (paneKey: string) => void + clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void pruneSleepingAgentSessions: (validWorktreeIds: Set) => void @@ -866,6 +867,41 @@ export const createAgentStatusSlice: StateCreator { + if (paneKeys.length === 0) { + return + } + const uniquePaneKeys = new Set(paneKeys) + set((s) => { + let nextSleeping = s.sleepingAgentSessionsByPaneKey + let nextLaunchConfigs = s.agentLaunchConfigByPaneKey + for (const paneKey of uniquePaneKeys) { + if (paneKey in nextSleeping) { + if (nextSleeping === s.sleepingAgentSessionsByPaneKey) { + nextSleeping = { ...nextSleeping } + } + delete nextSleeping[paneKey] + } + if (paneKey in nextLaunchConfigs) { + if (nextLaunchConfigs === s.agentLaunchConfigByPaneKey) { + nextLaunchConfigs = { ...nextLaunchConfigs } + } + delete nextLaunchConfigs[paneKey] + } + } + if ( + nextSleeping === s.sleepingAgentSessionsByPaneKey && + nextLaunchConfigs === s.agentLaunchConfigByPaneKey + ) { + return s + } + return { + sleepingAgentSessionsByPaneKey: nextSleeping, + agentLaunchConfigByPaneKey: nextLaunchConfigs + } + }) + } + return { agentStatusByPaneKey: {}, runtimeAgentOrchestrationByPaneKey: {}, @@ -2073,31 +2109,8 @@ export const createAgentStatusSlice: StateCreator { - set((s) => { - const hasSleepingRecord = paneKey in s.sleepingAgentSessionsByPaneKey - const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey - if (!hasSleepingRecord && !hasLaunchConfig) { - return s - } - const nextSleeping = hasSleepingRecord - ? { ...s.sleepingAgentSessionsByPaneKey } - : s.sleepingAgentSessionsByPaneKey - if (hasSleepingRecord) { - delete nextSleeping[paneKey] - } - const nextLaunchConfigs = hasLaunchConfig - ? { ...s.agentLaunchConfigByPaneKey } - : s.agentLaunchConfigByPaneKey - if (hasLaunchConfig) { - delete nextLaunchConfigs[paneKey] - } - return { - sleepingAgentSessionsByPaneKey: nextSleeping, - agentLaunchConfigByPaneKey: nextLaunchConfigs - } - }) - }, + clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]), + clearSleepingAgentSessionsByPaneKey, clearSleepingAgentSessionsByWorktree: (worktreeId) => { set((s) => { diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts index 7cc30477126..db9bee3a8d6 100644 --- a/src/shared/wsl-login-shell-command.test.ts +++ b/src/shared/wsl-login-shell-command.test.ts @@ -1,4 +1,7 @@ import { execFileSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { buildWslInteractiveLoginShellCommand, @@ -64,6 +67,61 @@ describe('wsl login shell command helpers', () => { expect(command).toContain("printf '\\''hello'\\''") }) + it.skipIf(process.platform === 'win32')( + 'resolves env-node launchers from the current login-shell PATH on every run', + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-wsl-login-codex-')) + const tools = join(root, 'tools') + const loginBin = join(root, 'login') + const v1Bin = join(root, 'nvm-v1') + const v2Bin = join(root, 'nvm-v2') + mkdirSync(tools) + mkdirSync(loginBin) + mkdirSync(v1Bin) + mkdirSync(v2Bin) + const loginShell = join(loginBin, 'bash') + writeFileSync( + join(tools, 'getent'), + `#!/bin/sh\nprintf '%s\\n' "user:x:1000:1000::/home/user:$ORCA_TEST_LOGIN_SHELL"\n` + ) + writeFileSync( + loginShell, + '#!/bin/sh\nexport PATH="$ORCA_TEST_CODEX_BIN:/usr/bin:/bin"\nexec /bin/sh -c "$2"\n' + ) + for (const [bin, label] of [ + [v1Bin, 'v1'], + [v2Bin, 'v2'] + ] as const) { + writeFileSync(join(bin, 'codex'), '#!/usr/bin/env node\n') + writeFileSync(join(bin, 'node'), `#!/bin/sh\nprintf '%s' '${label}'\n`) + chmodSync(join(bin, 'codex'), 0o755) + chmodSync(join(bin, 'node'), 0o755) + } + chmodSync(join(tools, 'getent'), 0o755) + chmodSync(loginShell, 0o755) + + const command = buildWslLoginShellCommand('exec codex') + const run = (codexBin: string): string => + execFileSync('/bin/sh', ['-c', command], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${tools}:/usr/bin:/bin`, + ORCA_TEST_LOGIN_SHELL: loginShell, + ORCA_TEST_CODEX_BIN: codexBin + } + }) + + try { + expect(run(v1Bin)).toBe('v1') + // The old launcher remains executable; current PATH precedence wins. + expect(run(v2Bin)).toBe('v2') + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) + it('preserves command-scoped environment variables through the outer WSL shell', () => { const command = buildWslLoginShellCommand('HISTFILE=/tmp/orca-history printf "$HISTFILE"') const escaped = escapeWslShCommandForWindows(command) From 79abeeaa2a053dc7455004504722fcedc921371a Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 10 Jul 2026 00:57:01 -0400 Subject: [PATCH 03/17] fix(browser): allow CDP browser target attach (#7033) (#7891) * fix(browser): allow CDP browser target attach (#7033) * review: preserve nested CDP session identity Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/main/browser/cdp-ws-proxy.test.ts | 142 ++++++++++++++++++++++++++ src/main/browser/cdp-ws-proxy.ts | 90 ++++++++++++++-- 2 files changed, 223 insertions(+), 9 deletions(-) diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 60248d21b15..5c366eda727 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -89,6 +89,148 @@ describe('CdpWsProxy', () => { client.close() }) + it('routes Playwright nested browser and page sessions on the proxy path', async () => { + const client = await connect(endpoint) + + const primaryPageAttachResponse = await sendAndReceive(client, { + id: 31, + method: 'Target.attachToTarget', + params: { targetId: 'orca-proxy-target', flatten: true } + }) + + expect(primaryPageAttachResponse).toEqual({ + id: 31, + result: { sessionId: 'orca-proxy-session' } + }) + + const attachResponse = await sendAndReceive(client, { + id: 32, + method: 'Target.attachToBrowserTarget', + params: {} + }) + + expect(attachResponse).toEqual({ + id: 32, + result: { sessionId: 'orca-proxy-browser-session' } + }) + expect(getSendCommandMethods(mock)).not.toContain('Target.attachToBrowserTarget') + + const pageAttachResponse = await sendAndReceive(client, { + id: 33, + method: 'Target.attachToTarget', + params: { targetId: 'orca-proxy-target', flatten: true }, + sessionId: 'orca-proxy-browser-session' + }) + + expect(pageAttachResponse).toEqual({ + id: 33, + result: { sessionId: 'orca-proxy-session-2' }, + sessionId: 'orca-proxy-browser-session' + }) + + const sessionResponse = await sendAndReceive(client, { + id: 34, + method: 'Runtime.evaluate', + params: { expression: 'document.title' }, + sessionId: 'orca-proxy-session-2' + }) + + expect(sessionResponse).toEqual({ + id: 34, + result: {}, + sessionId: 'orca-proxy-session-2' + }) + expect(getSendCommandCalls(mock)).toContainEqual([ + 'Runtime.evaluate', + { expression: 'document.title' } + ]) + + const detachResponse = await sendAndReceive(client, { + id: 35, + method: 'Target.detachFromTarget', + params: { sessionId: 'orca-proxy-session-2' }, + sessionId: 'orca-proxy-browser-session' + }) + + expect(detachResponse).toEqual({ + id: 35, + result: {}, + sessionId: 'orca-proxy-browser-session' + }) + + const primaryPageResponse = await sendAndReceive(client, { + id: 36, + method: 'Runtime.evaluate', + params: { expression: 'document.URL' }, + sessionId: 'orca-proxy-session' + }) + + expect(primaryPageResponse).toEqual({ + id: 36, + result: {}, + sessionId: 'orca-proxy-session' + }) + + const browserSessionResponse = await sendAndReceive(client, { + id: 37, + method: 'Target.getTargets', + params: {}, + sessionId: 'orca-proxy-browser-session' + }) + + expect(browserSessionResponse).toMatchObject({ + id: 37, + sessionId: 'orca-proxy-browser-session' + }) + client.close() + }) + + it('preserves Target.attachToTarget and clears the synthetic session on Target.detachFromTarget', async () => { + const client = await connect(endpoint) + + const attachResponse = await sendAndReceive(client, { + id: 33, + method: 'Target.attachToTarget', + params: { targetId: 'orca-proxy-target', flatten: true } + }) + + expect(attachResponse).toEqual({ + id: 33, + result: { sessionId: 'orca-proxy-session' } + }) + + const detachResponse = await sendAndReceive(client, { + id: 34, + method: 'Target.detachFromTarget', + params: { sessionId: 'orca-proxy-session' } + }) + + expect(detachResponse).toEqual({ id: 34, result: {} }) + + const rootEventPromise = new Promise>((resolve) => { + client.once('message', (data) => resolve(JSON.parse(data.toString()))) + }) + mock.emit('message', {}, 'Runtime.executionContextCreated', { context: { id: 1 } }) + + const rootEvent = await rootEventPromise + expect(rootEvent).toEqual({ + method: 'Runtime.executionContextCreated', + params: { context: { id: 1 } } + }) + + const reattachResponse = await sendAndReceive(client, { + id: 35, + method: 'Target.attachToTarget', + params: { targetId: 'orca-proxy-target', flatten: true } + }) + + expect(reattachResponse).toEqual({ + id: 35, + result: { sessionId: 'orca-proxy-session-2' } + }) + client.close() + }) + it('returns an error instead of crashing when a command arrives after tab destruction', async () => { const client = await connect(endpoint) mock.destroy() diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 62e51342d26..99c1d554654 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -19,6 +19,7 @@ export class CdpWsProxy { private httpServer: Server | null = null private wss: WebSocketServer | null = null private client: WebSocket | null = null + private readonly responseSessionIdsByClient = new WeakMap>() private detachClientListeners: (() => void) | null = null private port = 0 private debuggerMessageHandler: ((...args: unknown[]) => void) | null = null @@ -27,6 +28,10 @@ export class CdpWsProxy { private attached = false // Why: agent-browser filters events by sessionId from Target.attachToTarget. private clientSessionId: string | undefined = undefined + private readonly clientSessionIds = new Set() + private readonly clientBrowserSessionIds = new Set() + private nextClientSessionOrdinal = 0 + private nextClientBrowserSessionOrdinal = 0 private readonly pdfStreams = new CdpPdfStreamStore() constructor(private readonly webContents: WebContents) {} @@ -59,7 +64,7 @@ export class CdpWsProxy { const onClose = (): void => { detach() if (this.client === ws) { - this.pdfStreams.clear() + this.clearClientState() this.client = null } } @@ -110,18 +115,45 @@ export class CdpWsProxy { this.detachClientListeners?.() this.detachClientListeners = null this.client = null - // Why: a pending focus belongs to the departing client; never replay it for the next one. - this.pendingDomFocusBySession.clear() - this.pdfStreams.clear() + this.clearClientState() + if (client) { + this.responseSessionIdsByClient.delete(client) + } client?.close() } + private clearClientState(): void { + // Why: session and focus state belongs to one websocket and must not cross client replacement. + this.pendingDomFocusBySession.clear() + this.pdfStreams.clear() + this.clientSessionId = undefined + this.clientSessionIds.clear() + this.clientBrowserSessionIds.clear() + this.nextClientSessionOrdinal = 0 + this.nextClientBrowserSessionOrdinal = 0 + } + private send(payload: unknown, client = this.client): void { + const responsePayload = client ? this.addResponseSessionId(payload, client) : payload if (client?.readyState === WebSocket.OPEN) { - client.send(JSON.stringify(payload)) + client.send(JSON.stringify(responsePayload)) } } + private addResponseSessionId(payload: unknown, client: WebSocket): unknown { + if (typeof payload !== 'object' || payload === null) { + return payload + } + const clientId = (payload as { id?: unknown }).id + if (typeof clientId !== 'number') { + return payload + } + const responseSessionIds = this.responseSessionIdsByClient.get(client) + const sessionId = responseSessionIds?.get(clientId) + responseSessionIds?.delete(clientId) + return sessionId ? { ...payload, sessionId } : payload + } + private sendResult(clientId: number, result: unknown, client = this.client): void { this.send({ id: clientId, result }, client) } @@ -253,6 +285,13 @@ export class CdpWsProxy { return } const clientId = msg.id + const responseSessionIds = this.responseSessionIdsByClient.get(client) ?? new Map() + if (msg.sessionId) { + responseSessionIds.set(clientId, msg.sessionId) + } else { + responseSessionIds.delete(clientId) + } + this.responseSessionIdsByClient.set(client, responseSessionIds) if (msg.method === 'Target.getTargets') { this.sendResult(clientId, { targetInfos: [this.buildTargetInfo()] }, client) @@ -264,14 +303,30 @@ export class CdpWsProxy { } if (msg.method === 'Target.setDiscoverTargets' || msg.method === 'Target.detachFromTarget') { if (msg.method === 'Target.detachFromTarget') { - this.clientSessionId = undefined + const detachedSessionId = msg.params?.sessionId + if (typeof detachedSessionId === 'string') { + this.clientSessionIds.delete(detachedSessionId) + this.clientBrowserSessionIds.delete(detachedSessionId) + if (detachedSessionId === this.clientSessionId) { + this.clientSessionId = this.clientSessionIds.values().next().value + } + } } this.sendResult(clientId, {}, client) return } + if (msg.method === 'Target.attachToBrowserTarget') { + // Why: Playwright needs a distinct browser session before it attaches to the selected page. + const sessionId = this.nextSyntheticBrowserSessionId() + this.clientBrowserSessionIds.add(sessionId) + this.sendResult(clientId, { sessionId }, client) + return + } if (msg.method === 'Target.attachToTarget') { - this.clientSessionId = 'orca-proxy-session' - this.sendResult(clientId, { sessionId: this.clientSessionId }, client) + const sessionId = this.nextSyntheticPageSessionId() + this.clientSessionIds.add(sessionId) + this.clientSessionId ??= sessionId + this.sendResult(clientId, { sessionId }, client) return } if (msg.method === 'Browser.getVersion') { @@ -363,7 +418,24 @@ export class CdpWsProxy { } private resolveDebuggerSessionId(msgSessionId?: string): string | undefined { - return msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined + const syntheticSession = + (msgSessionId && this.clientSessionIds.has(msgSessionId)) || + (msgSessionId && this.clientBrowserSessionIds.has(msgSessionId)) + return msgSessionId && !syntheticSession ? msgSessionId : undefined + } + + private nextSyntheticPageSessionId(): string { + this.nextClientSessionOrdinal += 1 + return this.nextClientSessionOrdinal === 1 + ? 'orca-proxy-session' + : `orca-proxy-session-${this.nextClientSessionOrdinal}` + } + + private nextSyntheticBrowserSessionId(): string { + this.nextClientBrowserSessionOrdinal += 1 + return this.nextClientBrowserSessionOrdinal === 1 + ? 'orca-proxy-browser-session' + : `orca-proxy-browser-session-${this.nextClientBrowserSessionOrdinal}` } private isActiveClient(client: WebSocket): boolean { From 008ad62d3fe92ef8aaaf63392c8fa8faed509ffb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:57:38 -0700 Subject: [PATCH 04/17] Add HTML preview-to-side support for single unstaged/staged diffs (#8018) - Move the preview button before the single-diff tooltip in the header - Extend canOpenPreviewToSide to allow single diffs (not commit diffs) when the modified file still exists on disk, since the preview renders the working-tree file rather than diff content - Add tests covering HTML edit tabs, unstaged diffs, deleted files, commit diffs, and non-HTML diffs --- .../components/editor/EditorPanelHeader.tsx | 50 +++++++------- .../editor/editor-panel-render-model.test.ts | 67 ++++++++++++++++++- .../editor/editor-panel-render-model.ts | 18 +++-- 3 files changed, 103 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx index f47c9190ccd..23ce653055f 100644 --- a/src/renderer/src/components/editor/EditorPanelHeader.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx @@ -100,6 +100,31 @@ export function EditorPanelHeader({ onOpenMarkdownPreview={onOpenMarkdownPreview} onOpenContainingFolder={onOpenContainingFolder} /> + {canOpenPreviewToSide && ( + + + + + + + {translate( + 'auto.components.editor.EditorPanelHeader.fb8331694e', + 'Open Preview to the Side' + )} + + + + )} {isSingleDiff && ( @@ -149,31 +174,6 @@ export function EditorPanelHeader({ iconClassName="size-3" /> )} - {canOpenPreviewToSide && ( - - - - - - - {translate( - 'auto.components.editor.EditorPanelHeader.fb8331694e', - 'Open Preview to the Side' - )} - - - - )} {isDiffSurface && ( diff --git a/src/renderer/src/components/editor/editor-panel-render-model.test.ts b/src/renderer/src/components/editor/editor-panel-render-model.test.ts index 374e870e47f..88e3a539e63 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.test.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { OpenFile } from '@/store/slices/editor' import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' +import type { GitStatusEntry } from '../../../../shared/types' import type { FileContent } from './editor-panel-content-types' import { getEditorPanelRenderModel } from './editor-panel-render-model' @@ -31,18 +32,82 @@ function renderModel(args: { editorDrafts?: Record markdownViewMode?: Record isChangesMode?: boolean + gitStatusByWorktree?: Record }) { return getEditorPanelRenderModel({ activeFile: args.activeFile ?? markdownFile(), fileContents: args.fileContents ?? { '/repo/README.md': textContent() }, editorDrafts: args.editorDrafts ?? {}, - gitStatusByWorktree: {}, + gitStatusByWorktree: args.gitStatusByWorktree ?? {}, gitBranchChangesByWorktree: {}, markdownViewMode: args.markdownViewMode ?? {}, isChangesMode: args.isChangesMode ?? false }) } +function htmlFile(overrides: Partial = {}): OpenFile { + return { + id: '/repo/mock.html', + filePath: '/repo/mock.html', + relativePath: 'mock.html', + worktreeId: 'wt-1', + language: 'html', + mode: 'edit', + isDirty: false, + ...overrides + } +} + +describe('getEditorPanelRenderModel HTML preview affordance', () => { + it('enables preview for HTML edit tabs', () => { + expect(renderModel({ activeFile: htmlFile(), fileContents: {} }).canOpenPreviewToSide).toBe( + true + ) + }) + + it('enables preview for single HTML diffs whose file exists on disk', () => { + const model = renderModel({ + activeFile: htmlFile({ mode: 'diff', diffSource: 'unstaged' } as Partial), + fileContents: {}, + gitStatusByWorktree: { + 'wt-1': [{ path: 'mock.html', status: 'modified', area: 'unstaged' }] + } + }) + + expect(model.canOpenPreviewToSide).toBe(true) + }) + + it('disables preview for diffs of deleted HTML files', () => { + const model = renderModel({ + activeFile: htmlFile({ mode: 'diff', diffSource: 'unstaged' } as Partial), + fileContents: {}, + gitStatusByWorktree: { + 'wt-1': [{ path: 'mock.html', status: 'deleted', area: 'unstaged' }] + } + }) + + expect(model.canOpenPreviewToSide).toBe(false) + }) + + it('disables preview for commit diffs whose content may not match disk', () => { + const model = renderModel({ + activeFile: htmlFile({ mode: 'diff', diffSource: 'commit' } as Partial), + fileContents: {} + }) + + expect(model.canOpenPreviewToSide).toBe(false) + }) + + it('disables preview for non-HTML diffs', () => { + const model = renderModel({ + activeFile: markdownFile({ mode: 'diff', diffSource: 'unstaged' } as Partial), + fileContents: {} + }) + + expect(model.canOpenPreviewToSide).toBe(false) + }) +}) + describe('getEditorPanelRenderModel markdown export affordance', () => { it('enables export for rendered markdown edit tabs', () => { expect(renderModel({}).canExportMarkdownToPdf).toBe(true) diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index be9738db6a4..c471b2e73ad 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -71,6 +71,11 @@ export function getEditorPanelRenderModel({ activeFile.mode === 'diff' && activeFile.diffSource === 'branch' ? (branchEntries.find((entry) => entry.path === activeFile.relativePath) ?? null) : null + const openFileState = getEditorHeaderOpenFileState( + activeFile, + matchingWorktreeEntry, + matchingBranchEntry + ) const markdownViewModes = getMarkdownViewModes({ language: resolvedLanguage, mode: activeFile.mode, @@ -141,16 +146,17 @@ export function getEditorPanelRenderModel({ isCombinedDiff, worktreeEntries, resolvedLanguage, - openFileState: getEditorHeaderOpenFileState( - activeFile, - matchingWorktreeEntry, - matchingBranchEntry - ), + openFileState, isMarkdown: resolvedLanguage === 'markdown', isMermaid: resolvedLanguage === 'mermaid', isCsv: resolvedLanguage === 'csv' || resolvedLanguage === 'tsv', isNotebook: resolvedLanguage === 'notebook', - canOpenPreviewToSide: activeFile.mode === 'edit' && canPreviewLanguage(resolvedLanguage), + // Why: the preview renders the on-disk file, so diff surfaces only get it + // when the modified side still exists on disk (canOpen excludes deleted + // files and commit diffs whose content may not match the working tree). + canOpenPreviewToSide: + canPreviewLanguage(resolvedLanguage) && + (activeFile.mode === 'edit' || (isSingleDiff && openFileState.canOpen)), mdViewMode, hasViewModeToggle, availableEditorToggleModes, From 3a3e33f14b79cd40ebfbcc333b2b86035198f3ee Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:01:22 -0700 Subject: [PATCH 05/17] The `--sandbox` flag (or terminal sandboxing) in Google Antigravity is a (#8017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Here is a summary of how the sandbox behaves on your macOS system: ### ⚙️ How it Works When `--sandbox` is enabled (either via the launch flag or the `enableTerminalSandbox` setting in your `settings.json`), terminal commands run inside a lightweight containment boundary: - **macOS Native Isolation**: It utilizes macOS's native `sandbox-exec` utility to restrict system calls, network sockets, and directory access. - **Secure File Boundaries**: File system writes are locked down to designated safe zones (such as your designated workspace or scratch directory). Access to critical system paths, private user data, and external network resources is restricted. --- ### 🛡️ Active Permissions for this Session In this current session, the permission model is configured as follows: | Action / Resource | Permission Status | Details / Paths | | :--- | :--- | :--- | | **Command Execution** | ✅ **Allowed** | Terminal command execution is enabled. | | **File Reads (Allowed)** | ✅ **Allowed** | `/scratch`, `/browser_recordings`, `/html_artifacts`, `/knowledge`, `/worktrees`, `/skills`, `/builtin` | | **File Writes (Allowed)**| ✅ **Allowed** | `/scratch`, `/browser_recordings`, `/html_artifacts`, `/knowledge`, `/worktrees` | | **Sensitive Files** | ⚠️ **Ask** | `.env`, `.npmrc`, `.vscode`, `.git-credentials`, etc. | | **Root/App Settings** | 🚫 **Denied** | Direct modifications to `/config` and main `.gemini` configurations | --- ### 🔧 Configuration and Management * **Persistent Settings**: To enable sandboxing by default for all future sessions, configure the `enableTerminalSandbox` setting in your `~/.gemini/antigravity-cli/settings.json`: ```json { "enableTerminalSandbox": true } ``` * **Dynamic Adjustments**: Within an active CLI (`agy`) session, you can run the `/permissions` slash command to view or modify your autonomy and sandboxing levels on the fly. > [!NOTE] > Running in sandbox mode provides an excellent balance of autonomy and security, allowing me to execute build commands, run test scripts, and manage project files safely without risk to your primary host environment. Please let me know if you would like me to set up a new project workspace or run any specific tasks within this session! --- mobile/mock-source-control-hub.html | 2 +- mobile/src/components/MobileMarkdown.tsx | 2 +- mobile/src/components/MobilePRSidebar.tsx | 58 +++++--- .../src/components/MobilePrComposeSheet.tsx | 5 +- .../pr-sidebar/MobilePrViewPanel.tsx | 2 + .../pr-sidebar/PRActionsSection.tsx | 124 +++++++++++------- .../pr-sidebar/PRCommentsSection.tsx | 16 ++- .../pr-sidebar/PRReviewersSection.tsx | 46 ++++--- .../src/components/pr-sidebar/PRSection.tsx | 20 ++- .../components/pr-sidebar/PRSidebarHeader.tsx | 110 +++++++++------- .../pr-sidebar/ReviewerPickerDrawer.tsx | 48 +++---- .../pr-sidebar/mobile-pr-sidebar-styles.ts | 41 +++++- .../pr-sidebar/pr-actions-styles.ts | 13 ++ .../pr-sidebar/pr-comment-composer-styles.ts | 3 +- mobile/src/session/github-pr-rpc.test.ts | 17 +++ mobile/src/session/github-pr-value-readers.ts | 13 +- mobile/src/session/mobile-pr-sidebar-state.ts | 15 +++ mobile/src/session/pr-actions-engine.test.ts | 45 +++++++ mobile/src/session/pr-actions-engine.ts | 21 ++- .../use-mobile-pr-sidebar-controller.test.ts | 25 ++++ .../use-mobile-pr-sidebar-controller.ts | 30 +++-- .../MobileSourceControlPanel.tsx | 14 +- .../mobile-source-control-actions.test.ts | 4 +- .../mobile-source-control-actions.ts | 2 +- .../mobile-source-control-hub-styles.ts | 28 ++-- .../mobile-source-control-hub-tab.ts | 2 +- src/main/github/client.ts | 71 ++++++++-- 27 files changed, 558 insertions(+), 219 deletions(-) diff --git a/mobile/mock-source-control-hub.html b/mobile/mock-source-control-hub.html index 0a64d75ecbf..4e8035adef3 100644 --- a/mobile/mock-source-control-hub.html +++ b/mobile/mock-source-control-hub.html @@ -1165,7 +1165,7 @@ Changes - + diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index 5ee7ca6b4e7..3200dcc5d71 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -15,7 +15,7 @@ const MAX_TABLE_COLUMNS = 8 function openMarkdownUrl(url: string): void { const trimmed = url.trim() if (/^(https?:|mailto:)/i.test(trimmed)) { - void Linking.openURL(trimmed) + void Linking.openURL(trimmed).catch(() => {}) } } diff --git a/mobile/src/components/MobilePRSidebar.tsx b/mobile/src/components/MobilePRSidebar.tsx index 932f72e262a..42829df0310 100644 --- a/mobile/src/components/MobilePRSidebar.tsx +++ b/mobile/src/components/MobilePRSidebar.tsx @@ -39,6 +39,8 @@ type Props = { gitStatus: MobileGitStatusResult | null headSha: string | null bottomInset?: number + // Hub chrome already shows open-on-web; hide the in-body icon there. + showOpenOnWeb?: boolean } // Mutation hooks run unconditionally here and gate internally until a PR is ready. @@ -52,17 +54,15 @@ export function MobilePRSidebar({ gitBranch, gitStatus, headSha, - bottomInset = 0 + bottomInset = 0, + showOpenOnWeb = true }: Props) { const branch = prSidebarRenderBranch(state) // prNumber is 0 until ready; the hook gates on `ready` so it never fires early. const prNumber = state.kind === 'ready' ? state.data.pr.number : 0 - const prRepo = - state.kind === 'ready' - ? state.data.pr.prRepo - ? { owner: state.data.pr.prRepo.owner, repo: state.data.pr.prRepo.repo } - : null - : null + // Prefer the stable PRInfo.prRepo reference — cloning owner/repo each render + // reallocates and thrash-updates the mutation/comment/title hooks. + const prRepo = state.kind === 'ready' ? (state.data.pr.prRepo ?? null) : null const actions = useMobilePrActions({ client, connState, @@ -95,6 +95,9 @@ export function MobilePRSidebar({ style={{ flex: 1 }} contentContainerStyle={[styles.scrollContent, { paddingBottom: bottomInset }]} keyboardShouldPersistTaps="handled" + // Why: root-comment / reply composers sit at the bottom of this scroll + // area; without keyboard insets the focused field stays under the keyboard. + automaticallyAdjustKeyboardInsets showsVerticalScrollIndicator={false} > ) @@ -129,7 +133,8 @@ function PrSidebarContent({ actions, commentActions, titleAction, - triage + triage, + showOpenOnWeb }: { branch: ReturnType state: PrSidebarState @@ -144,6 +149,7 @@ function PrSidebarContent({ commentActions: MobilePrCommentActions titleAction: MobilePrTitleAction triage: MobilePrAiTriage + showOpenOnWeb: boolean }) { if (branch === 'loading') { return ( @@ -209,6 +215,7 @@ function PrSidebarContent({ titleAction={titleAction} triage={triage} refetch={refetch} + showOpenOnWeb={showOpenOnWeb} /> ) } @@ -223,7 +230,8 @@ function PrSidebarSections({ commentActions, titleAction, triage, - refetch + refetch, + showOpenOnWeb }: { data: Extract['data'] client: RpcClient | null @@ -233,6 +241,7 @@ function PrSidebarSections({ titleAction: MobilePrTitleAction triage: MobilePrAiTriage refetch: () => void + showOpenOnWeb: boolean }) { const pr = data.pr // Bind the triage launchers to this PR's data; the prompt builders are pure so @@ -262,19 +271,30 @@ function PrSidebarSections({ isBusy: triage.isBusy('resolve-conflicts'), error: triage.error } + // One card for identity + actions so the ready PR isn't a stack of thin + // duplicate blocks (badge row, title, branches, then another action band). return ( <> - - {/* Conflicting-files section mirrors desktop order: directly below the header, - before actions/checks. Renders only when the PR has merge conflicts. */} + + + + + + + {/* Own titled section when present; null otherwise (no empty chrome). */} - {}) } diff --git a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx index 5eb17e243d9..f587074e12e 100644 --- a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx +++ b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx @@ -62,6 +62,8 @@ export function MobilePrViewPanelBody({ gitStatus={gitStatus} headSha={headSha} bottomInset={insets.bottom} + // Hub header already hosts open-on-web while this segment is active. + showOpenOnWeb={false} /> ) diff --git a/mobile/src/components/pr-sidebar/PRActionsSection.tsx b/mobile/src/components/pr-sidebar/PRActionsSection.tsx index 86b910a08ca..5fae6803889 100644 --- a/mobile/src/components/pr-sidebar/PRActionsSection.tsx +++ b/mobile/src/components/pr-sidebar/PRActionsSection.tsx @@ -7,7 +7,6 @@ import type { RpcClient } from '../../transport/rpc-client' import type { MobilePrActions } from '../../session/use-mobile-pr-actions' import { unlinkMobilePr } from '../../source-control/mobile-pr-link' import { ConfirmModal } from '../ConfirmModal' -import { PRSection } from './PRSection' import { canShowMobilePRAutoMergeControl } from './pr-auto-merge-availability' import { resolveMobilePrMergeMethod, resolvePrActionAvailability } from './pr-actions-state' import { prActionsStyles as styles } from './pr-actions-styles' @@ -25,12 +24,13 @@ type Confirm = | { kind: 'merge'; method: GitHubPRMergeMethod } | { kind: 'state'; state: 'open' | 'closed' } -// Merge, auto-merge toggle, and close/reopen. Destructive actions route through -// ConfirmModal first (R5). The firing row shows a spinner in place of its icon -// and disables; other rows stay interactive (uniform visual). +// Merge primary; Close/Reopen + Unlink share one secondary row. No section title — +// button labels are self-explanatory and a header wasted a full row on mobile. export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }: Props) { const [confirm, setConfirm] = useState(null) const [unlinking, setUnlinking] = useState(false) + // Local unlink errors — unlink is not routed through the actions engine. + const [unlinkError, setUnlinkError] = useState(null) // Mobile keeps merge one-tap: use the repo default instead of surfacing a // desktop-style method picker in the narrow PR action stack. @@ -41,23 +41,31 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } const mergeBusy = actions.isBusy({ kind: 'merge' }) const autoMergeBusy = actions.isBusy({ kind: 'autoMerge' }) const stateBusy = actions.isBusy({ kind: 'state' }) + const unlinkBusy = unlinking || mergeBusy || autoMergeBusy || stateBusy const showAutoMerge = avail.canAutoMerge && canShowMobilePRAutoMergeControl({ ...pr, autoMergeEnabled: autoMerge || pr.autoMergeEnabled === true }) + const showSecondary = avail.canClose || avail.canReopen || avail.canUnlink + const actionError = unlinkError ?? actions.error const unlink = useCallback(async (): Promise => { if (!client || unlinking) { return } setUnlinking(true) + setUnlinkError(null) try { const outcome = await unlinkMobilePr(client, worktreeId) if (outcome.ok) { onUnlinked() + } else { + setUnlinkError(outcome.error) } + } catch (err) { + setUnlinkError(err instanceof Error ? err.message : 'Failed to unlink pull request.') } finally { setUnlinking(false) } @@ -89,6 +97,8 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } if (!confirm) { return } + // Engine errors take over the shared error line after this; drop unlink text. + setUnlinkError(null) if (confirm.kind === 'merge') { actions.merge(confirm.method) } else { @@ -99,8 +109,7 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } const copy = confirmCopy() return ( - - {/* Merge controls only while the PR can still be merged (open/draft). */} + {avail.canMerge ? ( setConfirm({ kind: 'merge', method: effectiveMethod })} + onPress={() => { + setUnlinkError(null) + setConfirm({ kind: 'merge', method: effectiveMethod }) + }} disabled={mergeBusy} accessibilityRole="button" accessibilityLabel="Merge pull request" @@ -124,13 +136,15 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } ) : null} - {/* Auto-merge toggle — optimistic, reverts on transient failure. */} {showAutoMerge ? ( Auto-merge when ready actions.setAutoMerge(!autoMerge, effectiveMethod)} + onPress={() => { + setUnlinkError(null) + actions.setAutoMerge(!autoMerge, effectiveMethod) + }} disabled={autoMergeBusy} accessibilityRole="switch" accessibilityState={{ checked: autoMerge }} @@ -147,49 +161,59 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } ) : null} - {/* Close (open PRs) / Reopen (closed PRs) — confirmed before firing (R5). */} - {avail.canClose || avail.canReopen ? ( - setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' })} - disabled={stateBusy} - accessibilityRole="button" - accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'} - > - {stateBusy ? : null} - - {avail.canClose ? 'Close' : 'Reopen'} - - + {showSecondary ? ( + + {avail.canClose || avail.canReopen ? ( + { + setUnlinkError(null) + setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' }) + }} + disabled={stateBusy} + accessibilityRole="button" + accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'} + > + {stateBusy ? : null} + + {avail.canClose ? 'Close' : 'Reopen'} + + + ) : null} + {avail.canUnlink ? ( + void unlink()} + disabled={unlinkBusy} + accessibilityRole="button" + accessibilityLabel="Unlink pull request" + > + {unlinking ? ( + + ) : ( + + )} + Unlink + + ) : null} + ) : null} - {/* Unlink the PR from this worktree. Disabled while another PR mutation is in - flight so clearing the link can't race a merge/close refetch. */} - {avail.canUnlink ? ( - void unlink()} - disabled={unlinking || mergeBusy || autoMergeBusy || stateBusy} - accessibilityRole="button" - accessibilityLabel="Unlink pull request" - > - {unlinking ? ( - - ) : ( - - )} - Unlink - - ) : null} + {actionError ? {actionError} : null} - {actions.error ? {actions.error} : null} - - {/* A Modal is taken out of the flex flow, so it adds no body gap here. */} setConfirm(null)} /> - + ) } diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx index 051f1392e9d..b6c0a1009d6 100644 --- a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx @@ -5,6 +5,7 @@ import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/type import type { GitHubPrRepoSlug } from '../../session/github-pr-rpc' import { colors } from '../../theme/mobile-theme' import { canAddRootComment } from '../../session/pr-comment-actions' +import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state' import type { MobilePrCommentActions } from '../../session/use-mobile-pr-comment-actions' import { PRSection } from './PRSection' import { CommentMarkdown } from './CommentMarkdown' @@ -50,10 +51,15 @@ const COMMENT_PAGE = 12 // review comments, reactions, and collapsible resolved threads. export function PRCommentsSection({ details, prState, prRepo, actions }: Props) { // details is null while phase 2 (the heavy comments/body payload) is still loading. + // A synthetic placeholder means phase 2 failed — do not paint that as empty success. const loadingDetails = details === null + const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details) const body = details?.body ?? '' - const comments = useMemo(() => details?.comments ?? [], [details]) - const isPr = details?.item.type === 'pr' + const comments = useMemo( + () => (details && !isPrSidebarDetailsPlaceholder(details) ? details.comments : []), + [details] + ) + const isPr = details != null && !detailsFailed && details.item.type === 'pr' // Per-card action bundle (stable callbacks from the hook) — built once so the // memo'd cards don't re-render on unrelated timeline changes. @@ -94,6 +100,10 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props) {loadingDetails ? ( + ) : detailsFailed ? ( + + Could not load description. Tap refresh to try again. + ) : body.trim() ? ( ) : ( @@ -113,6 +123,8 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props) > {loadingDetails ? ( + ) : detailsFailed ? ( + Could not load comments. Tap refresh to try again. ) : ( {comments.length === 0 ? ( diff --git a/mobile/src/components/pr-sidebar/PRReviewersSection.tsx b/mobile/src/components/pr-sidebar/PRReviewersSection.tsx index ad16241c206..bc09ecd3502 100644 --- a/mobile/src/components/pr-sidebar/PRReviewersSection.tsx +++ b/mobile/src/components/pr-sidebar/PRReviewersSection.tsx @@ -5,6 +5,7 @@ import { colors } from '../../theme/mobile-theme' import type { GitHubWorkItemDetails } from '../../../../src/shared/types' import type { RpcClient } from '../../transport/rpc-client' import type { MobilePrActions } from '../../session/use-mobile-pr-actions' +import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state' import { getPRReviewerRows } from './pr-checks-presentation' import { ReviewerPickerDrawer } from './ReviewerPickerDrawer' import { PRSection } from './PRSection' @@ -20,8 +21,17 @@ type Props = { // Requested reviewers + their latest review status, with a picker to request / // remove (optimistic add/remove via the actions hook). export function PRReviewersSection({ details, actions, client, worktreeId }: Props) { + // details === null means phase 2 (work-item payload) is still in flight — same + // signal Comments uses. Do not treat that as "no reviewers" or the section goes + // blank while checks (phase 1) already paint. A synthetic placeholder means + // phase 2 failed (no body/comments/reviews landed). + const loadingDetails = details === null + const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details) const authoritativeRows = useMemo( - () => (details?.item ? getPRReviewerRows(details.item) : []), + () => + details?.item && !isPrSidebarDetailsPlaceholder(details) + ? getPRReviewerRows(details.item) + : [], [details] ) const [pickerOpen, setPickerOpen] = useState(false) @@ -46,21 +56,27 @@ export function PRReviewersSection({ details, actions, client, worktreeId }: Pro return author ? [author, ...logins] : logins }, [authoritativeRows, details]) - return ( - setPickerOpen(true)} - accessibilityRole="button" - accessibilityLabel="Add or remove reviewers" - > - - - } + const addButton = ( + setPickerOpen(true)} + accessibilityRole="button" + accessibilityLabel="Add or remove reviewers" > - {rows.length === 0 ? ( + + + ) + + return ( + + {loadingDetails ? ( + + + Loading reviewers… + + ) : detailsFailed ? ( + Could not load reviewers. Tap refresh to try again. + ) : rows.length === 0 ? ( No reviewers requested ) : ( rows.map((row) => { diff --git a/mobile/src/components/pr-sidebar/PRSection.tsx b/mobile/src/components/pr-sidebar/PRSection.tsx index a573258d41a..0ed5ce90416 100644 --- a/mobile/src/components/pr-sidebar/PRSection.tsx +++ b/mobile/src/components/pr-sidebar/PRSection.tsx @@ -3,22 +3,28 @@ import { Text, View } from 'react-native' import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles' type Props = { - title: string + // Optional: omit for self-explanatory sections (e.g. action buttons) so the + // header row doesn't waste vertical space on mobile. + title?: string // Optional trailing control(s) in the header row (e.g. add-reviewer, checks // summary + rerun). Rendered right-aligned opposite the title. trailing?: ReactNode children: ReactNode } -// Shared card shell for the titled PR sections (Actions/Reviewers/Checks). Mirrors -// the desktop PR page's card-with-header-divider so the sections read consistently. +// Shared card shell for PR sections (Actions/Reviewers/Checks). Mirrors the +// desktop PR page's card-with-header-divider so the sections read consistently. +// Header is omitted when neither title nor trailing is provided. export function PRSection({ title, trailing, children }: Props) { + const showHeader = Boolean(title) || trailing != null return ( - - {title} - {trailing ? {trailing} : null} - + {showHeader ? ( + + {title ? {title} : null} + {trailing ? {trailing} : null} + + ) : null} {children} ) diff --git a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx index bf083303132..8a399babe78 100644 --- a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx +++ b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx @@ -16,11 +16,21 @@ type Props = { details: GitHubWorkItemDetails | null // Inline title-edit action; the pencil affordance only shows when the PR is editable. titleAction: MobilePrTitleAction + // Hub chrome already surfaces open-on-web; hide the duplicate icon in that case. + showOpenOnWeb?: boolean + // When true, render without section chrome so identity can share a card with actions. + bare?: boolean } -// Header: state badge (incl. draft — display-only), title, author, head->base. -// The title is inline-editable on an open/draft PR (desktop parity). -export function PRSidebarHeader({ pr, details, titleAction }: Props) { +// Compact identity: state + # + author on one meta row, title, head→base. +// # lives only in the meta row (not also after the title) to avoid repetition. +export function PRSidebarHeader({ + pr, + details, + titleAction, + showOpenOnWeb = true, + bare = false +}: Props) { const item = details?.item const badge = prStateBadge(pr.state) const badgeColor = statusColor(badge.token) @@ -29,14 +39,12 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) { const baseRef = item?.baseRefName ?? null const headRef = item?.branchName ?? null const editable = canEditPRTitle(pr.state) - // Badge, #number, and the flush-right external-link control all open pr.url - // (canonical host web URL) in the phone browser. const openPr = pr.url ? () => openMobilePrUrl(pr.url) : undefined - return ( - - - + const body = ( + <> + + {badge.label} - {openPr ? ( - [styles.iconButton, pressed && { opacity: 0.6 }]} - > - - - ) : null} + + #{pr.number} + + {author ? · {author} : null} - - {author ? by {author} : null} - {baseRef && headRef ? ( - // head -> base reads in merge direction (desktop ChecksPanel parity). - - {headRef} - - {baseRef} - + {showOpenOnWeb && openPr ? ( + [styles.iconButton, pressed && { opacity: 0.6 }]} + > + + ) : null} + + {baseRef && headRef ? ( + + + {headRef} + + + + {baseRef} + + + ) : null} + + ) + + if (bare) { + return {body} + } + return ( + + {body} ) } function PRTitle({ title, - number, editable, - openPr, titleAction }: { title: string - number: number editable: boolean - openPr: (() => void) | undefined titleAction: MobilePrTitleAction }) { const [editing, setEditing] = useState(false) @@ -119,7 +137,7 @@ function PRTitle({ if (editing) { return ( - + - - {title}{' '} - - #{number} - - + {title} {editable ? ( diff --git a/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx b/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx index 97d89a34b3e..8a3a161f358 100644 --- a/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx +++ b/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { ActivityIndicator, FlatList, Pressable, Text, TextInput, View } from 'react-native' +import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' import { Check } from 'lucide-react-native' import { colors } from '../../theme/mobile-theme' import type { GitHubAssignableUser } from '../../../../src/shared/types' @@ -26,9 +26,10 @@ type LoadState = | { status: 'error'; message: string } | { status: 'loaded'; users: GitHubAssignableUser[] } -// A search + FlatList of github.listAssignableUsers in a BottomDrawer (not a new -// primitive). Seeded with already-requested/latest reviewers + author at the top. -// Optimistic add/remove via onToggle. +// Searchable assignable-user list in a BottomDrawer. Mapped rows (not FlatList): +// this drawer is opened from the PR ScrollView, and a VirtualizedList nested in +// that ScrollView throws and can leave the Reviewers section blank. +// Seeded reviewers + author sort first for quick un-request. export function ReviewerPickerDrawer({ visible, onClose, @@ -47,16 +48,22 @@ export function ReviewerPickerDrawer({ } let cancelled = false setLoad({ status: 'loading' }) - void fetchAssignableUsers(client, worktreeId).then((outcome) => { - if (cancelled) { - return - } - setLoad( - outcome.ok - ? { status: 'loaded', users: outcome.result } - : { status: 'error', message: outcome.error } - ) - }) + void fetchAssignableUsers(client, worktreeId) + .then((outcome) => { + if (cancelled) { + return + } + setLoad( + outcome.ok + ? { status: 'loaded', users: outcome.result } + : { status: 'error', message: outcome.error } + ) + }) + .catch(() => { + if (!cancelled) { + setLoad({ status: 'error', message: 'Failed to load people' }) + } + }) return () => { cancelled = true } @@ -107,15 +114,12 @@ export function ReviewerPickerDrawer({ No matching people ) : ( - u.login} - keyboardShouldPersistTaps="handled" - renderItem={({ item }) => { + + {ordered.map((item) => { const requested = isRequested(item.login) return ( onToggle(item.login)} accessibilityRole="button" @@ -134,8 +138,8 @@ export function ReviewerPickerDrawer({ ) - }} - /> + })} + )} ) diff --git a/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts b/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts index 1c66ed7a48b..edf308350e4 100644 --- a/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts +++ b/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts @@ -25,8 +25,7 @@ export const mobilePrSidebarStyles = StyleSheet.create({ section: { backgroundColor: colors.bgPanel, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle, - overflow: 'hidden' + borderBottomColor: colors.borderSubtle }, // Section header row: title + optional trailing control, divided from the body // by a hairline border (desktop `h-10 border-b px-3`). @@ -55,14 +54,25 @@ export const mobilePrSidebarStyles = StyleSheet.create({ fontSize: 13, fontWeight: '600' }, - // Header section: state badge, title, author, base<-head branches. - // Badge + trailing open-on-web icon (hub chromeless has no panel chrome for it). - badgeRow: { + // Identity + actions share one section card (hub redesign): no per-block chrome. + identityBlock: { + gap: spacing.sm + }, + // State badge + #number + author on one row; open-on-web flush right when shown. + metaRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing.sm }, + metaLeft: { + flex: 1, + minWidth: 0, + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: spacing.xs + }, badge: { alignSelf: 'flex-start', paddingHorizontal: spacing.sm, @@ -76,6 +86,7 @@ export const mobilePrSidebarStyles = StyleSheet.create({ fontWeight: '700' }, prTitle: { + flex: 1, color: colors.textPrimary, fontSize: typography.titleSize, fontWeight: '700', @@ -85,6 +96,12 @@ export const mobilePrSidebarStyles = StyleSheet.create({ color: colors.textSecondary, fontSize: typography.metaSize }, + // #number in the meta row — stronger than author so the identity scans first. + prMetaStrong: { + color: colors.textPrimary, + fontSize: typography.metaSize, + fontWeight: '600' + }, // Title row: tappable area pairing the title with a trailing edit affordance. titleRow: { flexDirection: 'row', @@ -104,10 +121,11 @@ export const mobilePrSidebarStyles = StyleSheet.create({ gap: spacing.xs }, branchPill: { + flexShrink: 1, color: colors.textPrimary, fontSize: typography.metaSize, fontFamily: typography.monoFamily, - backgroundColor: colors.bgPanel, + backgroundColor: colors.bgRaised, paddingHorizontal: spacing.xs, paddingVertical: 2, borderRadius: radii.button @@ -146,6 +164,13 @@ export const mobilePrSidebarStyles = StyleSheet.create({ color: colors.textSecondary, fontSize: typography.metaSize }, + // Loading / empty status row inside Reviewers (and similar) section bodies. + reviewersStatus: { + minHeight: 44, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, summaryLabel: { fontSize: typography.bodySize, fontWeight: '700' @@ -274,8 +299,10 @@ export const mobilePrSidebarStyles = StyleSheet.create({ fontSize: typography.bodySize, marginBottom: spacing.sm }, + // No maxHeight / FlatList: the parent BottomDrawer scrolls this block so we + // never nest a VirtualizedList inside the PR page ScrollView. pickerList: { - maxHeight: 320 + gap: 0 }, pickerRow: { minHeight: 44, diff --git a/mobile/src/components/pr-sidebar/pr-actions-styles.ts b/mobile/src/components/pr-sidebar/pr-actions-styles.ts index 865516e3468..6d89e75bf98 100644 --- a/mobile/src/components/pr-sidebar/pr-actions-styles.ts +++ b/mobile/src/components/pr-sidebar/pr-actions-styles.ts @@ -5,6 +5,19 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme' // line). Split out of mobile-pr-sidebar-styles to keep that file under the // 300-line cap. export const prActionsStyles = StyleSheet.create({ + // Bare block when identity + actions share one section card. + actionsBlock: { + gap: spacing.sm + }, + // Close/Reopen + Unlink share a row so secondary actions don't stack full-width. + secondaryRow: { + flexDirection: 'row', + alignItems: 'stretch', + gap: spacing.sm + }, + secondaryButton: { + flex: 1 + }, // Primary CTA (merge) and secondary action buttons (close/reopen/rerun/add). actionButton: { minHeight: 44, diff --git a/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts b/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts index 3d44a9e0cc2..e6cb4966ca2 100644 --- a/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts +++ b/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts @@ -5,7 +5,8 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme' // match the PR comment timeline; split out to keep PRCommentComposer focused. export const prCommentComposerStyles = StyleSheet.create({ container: { - gap: spacing.sm + // Input → Cancel/Save needs clear separation (title edit was flush without this). + gap: spacing.md }, input: { minHeight: 64, diff --git a/mobile/src/session/github-pr-rpc.test.ts b/mobile/src/session/github-pr-rpc.test.ts index 1adb72ec8b8..56904e482cd 100644 --- a/mobile/src/session/github-pr-rpc.test.ts +++ b/mobile/src/session/github-pr-rpc.test.ts @@ -172,6 +172,23 @@ describe('readWorkItemDetails', () => { expect(parsed?.item.latestReviews).toEqual([{ login: 'ok', state: null, avatarUrl: null }]) }) + it('accepts raw gh latestReviews author.login nesting', () => { + const parsed = readWorkItemDetails({ + item: { + id: 'n', + type: 'pr', + number: 1, + state: 'open', + latestReviews: [ + { author: { login: 'coderabbitai', avatarUrl: 'https://a' }, state: 'COMMENTED' } + ] + } + }) + expect(parsed?.item.latestReviews).toEqual([ + { login: 'coderabbitai', state: 'COMMENTED', avatarUrl: 'https://a' } + ]) + }) + it('returns null when item is unparseable', () => { expect(readWorkItemDetails({ item: { number: 1 } })).toBeNull() expect(readWorkItemDetails(null)).toBeNull() diff --git a/mobile/src/session/github-pr-value-readers.ts b/mobile/src/session/github-pr-value-readers.ts index e7dcf9313ba..6ba8ccd6954 100644 --- a/mobile/src/session/github-pr-value-readers.ts +++ b/mobile/src/session/github-pr-value-readers.ts @@ -123,14 +123,23 @@ export function readReviewSummary(value: unknown): GitHubPRReviewSummary | null if (!isRecord(value)) { return null } - const login = readString(value.login) + // Desktop maps latestReviews to top-level `login`. Raw `gh pr view --json` + // keeps nested `author.login` — accept both so mobile never drops reviewers. + const nestedAuthor = isRecord(value.author) ? value.author : null + const login = + readString(value.login) ?? (nestedAuthor ? readString(nestedAuthor.login) : undefined) if (login === undefined) { return null } + const avatarUrl = + readString(value.avatarUrl) ?? + (nestedAuthor + ? (readString(nestedAuthor.avatarUrl) ?? readString(nestedAuthor.avatar_url) ?? null) + : null) return { login, state: readString(value.state) ?? null, - avatarUrl: readString(value.avatarUrl) ?? null + avatarUrl } } diff --git a/mobile/src/session/mobile-pr-sidebar-state.ts b/mobile/src/session/mobile-pr-sidebar-state.ts index 4ce32a7cec7..1b4d9a1b50b 100644 --- a/mobile/src/session/mobile-pr-sidebar-state.ts +++ b/mobile/src/session/mobile-pr-sidebar-state.ts @@ -157,6 +157,7 @@ export function resolvePrSidebarDetailsAfterPhase2(args: { } // Placeholder details so Description/Comments leave the spinner after a failed phase 2. +// The synthetic id is the only id shape we mint — real GitHub node ids never match. export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails { return { item: { @@ -175,6 +176,20 @@ export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails { } } +// True when phase 2 failed and we synthesized a stand-in (no body/comments/reviews). +export function isPrSidebarDetailsPlaceholder(details: GitHubWorkItemDetails): boolean { + return details.item.id === `pr-${details.item.number}` +} + +// Phase-2 still needs a real payload: null (in flight / not started) or a +// synthetic placeholder from a failed fetch. Real details (including empty body) +// do not qualify — ensure/retry must not re-pull those. +export function prSidebarDetailsNeedFetch( + details: GitHubWorkItemDetails | null | undefined +): boolean { + return details == null || isPrSidebarDetailsPlaceholder(details) +} + // Soft head refresh may restart an in-flight phase-1 load; only skip when there is // no visible/in-flight sidebar work (hidden or terminal error states). export function shouldSoftRefreshPrSidebarOnHeadChange(kind: PrSidebarState['kind']): boolean { diff --git a/mobile/src/session/pr-actions-engine.test.ts b/mobile/src/session/pr-actions-engine.test.ts index 72bde872409..d879454c50c 100644 --- a/mobile/src/session/pr-actions-engine.test.ts +++ b/mobile/src/session/pr-actions-engine.test.ts @@ -80,6 +80,51 @@ describe('PrActionsEngine — transport-rejection-normalized outcomes settle cle expect(engine.error).toBe('socket hung up') expect(engine.busy).toBeNull() }) + + it('surfaces a refetch throw as error without rejecting the action promise', async () => { + const engine = new PrActionsEngine({ + mutations: { + mergePR: async () => ({ ok: true }), + setPRAutoMerge: async () => ({ ok: true }), + updatePRState: async () => ({ ok: true }), + requestReviewers: async () => ({ ok: true }), + removeReviewers: async () => ({ ok: true }), + rerunChecks: async () => ({ ok: true }) + }, + prNumber: 1, + refetch: async () => { + throw new Error('refresh failed') + }, + onChange: () => {} + }) + await expect(engine.merge()).resolves.toBeUndefined() + expect(engine.error).toBe('refresh failed') + expect(engine.busy).toBeNull() + }) + + it('does not notify on no-op setError(null) at action start', async () => { + const onChange = vi.fn() + const engine = new PrActionsEngine({ + mutations: { + mergePR: async () => ({ ok: true }), + setPRAutoMerge: async () => ({ ok: true }), + updatePRState: async () => ({ ok: true }), + requestReviewers: async () => ({ ok: true }), + removeReviewers: async () => ({ ok: true }), + rerunChecks: async () => ({ ok: true }) + }, + prNumber: 1, + refetch: () => {}, + onChange + }) + // Idle: error is already null. Action start must only notify for busy, not a + // redundant clearError path. + onChange.mockClear() + const p = engine.merge() + // First notify is setBusy only (setError(null) no-ops). + expect(onChange).toHaveBeenCalledTimes(1) + await p + }) }) describe('PrActionsEngine — PR identity changes', () => { diff --git a/mobile/src/session/pr-actions-engine.ts b/mobile/src/session/pr-actions-engine.ts index a647b62dfe9..7c34954534d 100644 --- a/mobile/src/session/pr-actions-engine.ts +++ b/mobile/src/session/pr-actions-engine.ts @@ -129,7 +129,12 @@ export class PrActionsEngine { return f } + // Why: action start pairs setBusy + setError(null); skip notify when unchanged + // so we don't force a full PR panel re-render for free. private setBusy(key: PrActionBusyKey | null): void { + if (key === null ? this.busy === null : busyKeyEquals(this.busy, key)) { + return + } this.busy = key this.cfg.onChange() } @@ -143,6 +148,9 @@ export class PrActionsEngine { } private setError(message: string | null): void { + if (this.error === message) { + return + } this.error = message this.cfg.onChange() } @@ -180,7 +188,14 @@ export class PrActionsEngine { } if (outcome.ok) { handlers.onSuccess() - await this.cfg.refetch() + // Why: void engine.merge() callers are fire-and-forget; refetch must not LogBox. + try { + await this.cfg.refetch() + } catch (err) { + if (this.identity === identity) { + this.setError(err instanceof Error ? err.message : 'Failed to refresh pull request.') + } + } return } // Both failure classes clear optimism to authoritative; only the message @@ -205,6 +220,10 @@ export class PrActionsEngine { prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => {}, onRevert: () => {} }) + } catch (err) { + if (this.identity === identity) { + this.setError(err instanceof Error ? err.message : 'Failed to merge pull request.') + } } finally { this.clearBusyIfOwned(identity, { kind: 'merge' }) } diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts index de77433fcb6..f402f7b58ad 100644 --- a/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts +++ b/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts @@ -5,6 +5,8 @@ import type { GitHubPrReadOutcome } from './github-pr-rpc' import { classifyPrSidebarFailure, emptyPrSidebarDetails, + isPrSidebarDetailsPlaceholder, + prSidebarDetailsNeedFetch, loadPrSidebarData, loadPrSidebarDetails, resolvePrSidebarDetailsAfterPhase2, @@ -240,6 +242,29 @@ describe('resolvePrSidebarDetailsAfterPhase2', () => { expect(empty.comments).toEqual([]) expect(empty.item.number).toBe(7) expect(empty.item.type).toBe('pr') + expect(isPrSidebarDetailsPlaceholder(empty)).toBe(true) + }) +}) + +describe('isPrSidebarDetailsPlaceholder / prSidebarDetailsNeedFetch', () => { + it('detects the synthetic hyphen id, not the host pr:n shape', () => { + const placeholder = emptyPrSidebarDetails(PR) + expect(isPrSidebarDetailsPlaceholder(placeholder)).toBe(true) + expect(isPrSidebarDetailsPlaceholder(DETAILS)).toBe(false) + // Host work-item ids use a colon (`pr:7`); placeholders use a hyphen (`pr-7`). + expect( + isPrSidebarDetailsPlaceholder({ + ...DETAILS, + item: { ...DETAILS.item, id: `pr:${PR.number}` } + }) + ).toBe(false) + }) + + it('needs fetch for null and placeholders, not real details', () => { + expect(prSidebarDetailsNeedFetch(null)).toBe(true) + expect(prSidebarDetailsNeedFetch(undefined)).toBe(true) + expect(prSidebarDetailsNeedFetch(emptyPrSidebarDetails(PR))).toBe(true) + expect(prSidebarDetailsNeedFetch(DETAILS)).toBe(false) }) }) diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.ts index 415a7581b7d..752b2ecb3b8 100644 --- a/mobile/src/session/use-mobile-pr-sidebar-controller.ts +++ b/mobile/src/session/use-mobile-pr-sidebar-controller.ts @@ -11,6 +11,7 @@ import { import { loadPrSidebarData, loadPrSidebarDetails, + prSidebarDetailsNeedFetch, resolvePrSidebarDetailsAfterPhase2, shouldApplyResult, shouldSoftRefreshPrSidebarOnHeadChange, @@ -103,12 +104,21 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { if (!probeReady || !client) { return } - void fetchGithubRepoSlug(client, worktreeId).then((outcome) => { - if (!cancelled) { - setIsGithubRepo(outcome.ok && outcome.result !== null) - setRepoProbeLoaded(true) - } - }) + void fetchGithubRepoSlug(client, worktreeId) + .then((outcome) => { + if (!cancelled) { + setIsGithubRepo(outcome.ok && outcome.result !== null) + setRepoProbeLoaded(true) + } + }) + .catch(() => { + // Why: sendGithubPrRead already normalizes throws, but a cancelled + // unmount + any unexpected rejection must not surface as LogBox. + if (!cancelled) { + setIsGithubRepo(false) + setRepoProbeLoaded(true) + } + }) return () => { cancelled = true } @@ -228,9 +238,12 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { // Phase-2 only — used when the hub opens the PR segment after a chip-only load. // Uses detailsSeqRef (not loadSeqRef) so it cannot cancel a concurrent soft phase-1. + // Retries synthetic placeholders too: a failed phase-2 installs non-null empty + // details so Description/Comments leave the spinner, and without this ensure + // would never re-fetch on tab re-open. const ensurePrSidebarDetails = useCallback(async () => { const current = stateRef.current - if (current.kind !== 'ready' || current.data.details != null) { + if (current.kind !== 'ready' || !prSidebarDetailsNeedFetch(current.data.details)) { return } const deps = buildDeps() @@ -303,7 +316,8 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { (state.kind !== 'ready' && state.kind !== 'loading') ) { void load({ includeDetails: true }) - } else if (state.kind === 'ready' && state.data.details == null) { + } else if (state.kind === 'ready' && prSidebarDetailsNeedFetch(state.data.details)) { + // Retries phase-2 placeholders too (failed details load, not only null). void ensurePrSidebarDetails() } }, [identity, state, load, ensurePrSidebarDetails]) diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx index 332f62312f1..25656aae2bd 100644 --- a/mobile/src/source-control/MobileSourceControlPanel.tsx +++ b/mobile/src/source-control/MobileSourceControlPanel.tsx @@ -16,6 +16,7 @@ import type { SourceControlHubTab } from './mobile-source-control-hub-tab' import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary' import { isMobileConflictAborting } from './mobile-source-control-conflict-abort' import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller' +import { prSidebarDetailsNeedFetch } from '../session/mobile-pr-sidebar-state' import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel' import { openMobilePrUrl } from '../components/MobilePrComposeSheet' @@ -177,8 +178,11 @@ export function MobileSourceControlPanel({ // none). Keyed by PR number — not a boolean — so a same-branch PR swap during a // chip-only soft refresh re-arms phase 2 for the new PR instead of leaving its // comments on a forever-spinner (a stale ensure bails on the number mismatch). + // Placeholder details (failed phase 2) also count as missing so reopening the + // PR tab retries instead of leaving empty Description/Comments forever. const prDetailsMissingFor = - prController.prSidebarState.kind === 'ready' && prController.prSidebarState.data.details == null + prController.prSidebarState.kind === 'ready' && + prSidebarDetailsNeedFetch(prController.prSidebarState.data.details) ? prController.prSidebarState.data.pr.number : null @@ -299,6 +303,8 @@ export function MobileSourceControlPanel({ // Controller + chip state still live for instant re-open without a full cold start. const showPrBody = ready && activeTab === 'pr' const conflictOperation = status?.conflictOperation ?? null + // Git status always reports a conflictOperation enum; 'unknown' means none. + const hasActiveConflict = conflictOperation != null && conflictOperation !== 'unknown' const conflictAborting = isMobileConflictAborting(busyAction, conflictOperation) return ( @@ -313,7 +319,11 @@ export function MobileSourceControlPanel({ - {ready ? ( + {/* Branch card + PR chip are the Changes/Commits glance layer. On the PR + tab they duplicate the ready PR body (#, state, checks rollup, branch + trajectory), so hide the whole card there and let the PR panel own it — + unless a merge/rebase conflict is active, which only this card can abort. */} + {ready && (activeTab !== 'pr' || hasActiveConflict) ? ( { expect(labels.some((l) => l.startsWith('Fast-forward'))).toBe(true) expect(labels).toContain('Rebase onto base') expect(labels).toContain('Switch branch') - expect(labels).toContain('History') + expect(labels).toContain('Commits') expect(labels).toContain('Create PR') }) @@ -90,7 +90,7 @@ describe('buildMobileSourceControlActions', () => { const handlers = noopHandlers() const actions = buildMobileSourceControlActions(args({ handlers })) action(actions, 'Switch branch')?.onPress() - action(actions, 'History')?.onPress() + action(actions, 'Commits')?.onPress() expect(handlers.checkout).toHaveBeenCalled() expect(handlers.history).toHaveBeenCalled() }) diff --git a/mobile/src/source-control/mobile-source-control-actions.ts b/mobile/src/source-control/mobile-source-control-actions.ts index 0e504c5a2b1..b06af8a6aab 100644 --- a/mobile/src/source-control/mobile-source-control-actions.ts +++ b/mobile/src/source-control/mobile-source-control-actions.ts @@ -217,7 +217,7 @@ export function buildMobileSourceControlActions( onPress: handlers.checkout }, { - label: 'History', + label: 'Commits', iconKey: 'history', disabled: busy, onPress: handlers.history diff --git a/mobile/src/source-control/mobile-source-control-hub-styles.ts b/mobile/src/source-control/mobile-source-control-hub-styles.ts index 683fa3a1dcc..10d126177e8 100644 --- a/mobile/src/source-control/mobile-source-control-hub-styles.ts +++ b/mobile/src/source-control/mobile-source-control-hub-styles.ts @@ -4,29 +4,29 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme' // Styles for the hub's segmented control and the branch-card PR chip. Split from // mobile-source-control-styles.ts so neither file crosses the line limit. export const hubStyles = StyleSheet.create({ + // Full-width tab bar under the hub header. Edge-to-edge, no inset track and no + // inner padding — segments share height evenly so the control doesn't float in a + // pill frame with gaps above/below the active cell. segments: { flexDirection: 'row', - marginHorizontal: spacing.lg, - marginTop: spacing.sm, - padding: 3, - borderRadius: radii.button + 2, - backgroundColor: colors.bgRaised, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - gap: 2 + alignItems: 'stretch', + width: '100%', + backgroundColor: colors.bgPanel, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle }, segment: { flex: 1, - minHeight: 34, - borderRadius: radii.button, + minHeight: 40, alignItems: 'center', justifyContent: 'center', - paddingHorizontal: spacing.xs + paddingHorizontal: spacing.xs, + // Reserved so active underline doesn't change layout height. + borderBottomWidth: 2, + borderBottomColor: 'transparent' }, segmentActive: { - backgroundColor: colors.bgPanel, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle + borderBottomColor: colors.textPrimary }, segmentPressed: { opacity: 0.7 diff --git a/mobile/src/source-control/mobile-source-control-hub-tab.ts b/mobile/src/source-control/mobile-source-control-hub-tab.ts index e243032a58c..3a0a48406ce 100644 --- a/mobile/src/source-control/mobile-source-control-hub-tab.ts +++ b/mobile/src/source-control/mobile-source-control-hub-tab.ts @@ -13,7 +13,7 @@ export const SOURCE_CONTROL_HUB_TABS: readonly SourceControlHubTab[] = [ export const SOURCE_CONTROL_HUB_TAB_LABELS: Record = { changes: 'Changes', pr: 'Pull Request', - history: 'History' + history: 'Commits' } // Normalize a route param (possibly an array from expo-router, possibly unknown) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 3a308a3adfb..6e4005421a4 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -848,6 +848,43 @@ async function fetchIssueWorkItem( return mapIssueWorkItem(JSON.parse(stdout) as Record) } +// REST /pulls/{n} has requested_reviewers but not latestReviews. When the JSON +// `gh pr view` path fails, still pull review fields from gh so mobile/desktop +// reviewer lists (CodeRabbit COMMENTED, etc.) are not silently empty. +const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews' + +async function fetchPullRequestReviewFields( + number: number, + ownerRepo: OwnerRepo | null, + ghOptions: GhExecOptions +): Promise> { + try { + const args = ownerRepo + ? [ + 'pr', + 'view', + String(number), + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--json', + WORK_ITEM_PR_REVIEW_JSON_FIELDS + ] + : ['pr', 'view', String(number), '--json', WORK_ITEM_PR_REVIEW_JSON_FIELDS] + const { stdout } = await ghExecFileAsync(args, ghOptions) + const item = JSON.parse(stdout) as Record + return { + ...(item.reviewRequests !== undefined + ? { reviewRequests: usersFromUnknown(item.reviewRequests) } + : {}), + ...(item.latestReviews !== undefined + ? { latestReviews: latestReviewsFromUnknown(item.latestReviews) } + : {}) + } + } catch { + return {} + } +} + async function fetchPullRequestWorkItem( repoPath: string, ownerRepo: OwnerRepo | null, @@ -872,24 +909,36 @@ async function fetchPullRequestWorkItem( ) const item = JSON.parse(stdout) as Record const mapped = mapPullRequestWorkItem(item, ownerRepo) + // Why: merge-metadata GraphQL is best-effort. A failure here must not fall + // through to the REST path below — that path drops latestReviews and blanks + // the mobile/desktop reviewer list for bots that only left a review. const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined - const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions) - return { - ...mapped, - mergeQueueRequired: mergeMetadata.mergeQueueRequired, - ...(mergeMetadata.autoMergeAllowed !== null - ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } - : {}), - ...(mergeMetadata.mergeMethodSettings - ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings } - : {}) + try { + const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions) + return { + ...mapped, + mergeQueueRequired: mergeMetadata.mergeQueueRequired, + ...(mergeMetadata.autoMergeAllowed !== null + ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } + : {}), + ...(mergeMetadata.mergeMethodSettings + ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings } + : {}) + } + } catch { + return mapped } } catch { const { stdout } = await ghExecFileAsync( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], ghOptions ) - return mapPullRequestWorkItem(JSON.parse(stdout) as Record, ownerRepo) + const mapped = mapPullRequestWorkItem( + JSON.parse(stdout) as Record, + ownerRepo + ) + const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions) + return { ...mapped, ...reviewFields } } } From 693ac1fb25e435ef6d7afd891cd38766ac1888cd Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 10 Jul 2026 01:12:04 -0400 Subject: [PATCH 06/17] fix(browser): import Chromium cookies from Network DBs (#6875) (#7882) * fix(browser): import Chromium cookies from Network DBs (#6875) * fix(browser): complete Chromium cookie import paths Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../browser-cookie-import-test-database.ts | 82 ++++++++ .../browser/browser-cookie-import.test.ts | 184 +++++++++++++++++- src/main/browser/browser-cookie-import.ts | 106 ++++------ ...owser-session-registry.persistence.test.ts | 25 +++ src/main/browser/browser-session-registry.ts | 6 +- src/main/browser/chromium-cookie-path.ts | 13 ++ .../orca-profiles/profile-cloud-dev-auth.ts | 5 +- .../profile-cloud-service-auth-retry.test.ts | 4 +- .../profile-cloud-service-refresh.test.ts | 4 +- .../profile-cloud-service.test.ts | 4 +- 10 files changed, 351 insertions(+), 82 deletions(-) create mode 100644 src/main/browser/browser-cookie-import-test-database.ts create mode 100644 src/main/browser/chromium-cookie-path.ts diff --git a/src/main/browser/browser-cookie-import-test-database.ts b/src/main/browser/browser-cookie-import-test-database.ts new file mode 100644 index 00000000000..ead377e5b46 --- /dev/null +++ b/src/main/browser/browser-cookie-import-test-database.ts @@ -0,0 +1,82 @@ +import { createCipheriv, pbkdf2Sync } from 'node:crypto' +import { mkdirSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { join } from 'node:path' + +type ChromiumCookieTestRow = { + name: string + value: string + encryptedValue?: Buffer +} + +export function createChromiumCookieTestDatabase( + databasePath: string, + rows: ChromiumCookieTestRow[], + options: { journalMode?: 'wal' } = {} +): DatabaseSync { + mkdirSync(join(databasePath, '..'), { recursive: true }) + const database = new DatabaseSync(databasePath) + if (options.journalMode === 'wal') { + database.exec('PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0') + } + database.exec(` + CREATE TABLE cookies ( + creation_utc INTEGER NOT NULL, + host_key TEXT NOT NULL, + top_frame_site_key TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + value TEXT NOT NULL, + encrypted_value BLOB NOT NULL DEFAULT X'', + path TEXT NOT NULL, + expires_utc INTEGER NOT NULL, + is_secure INTEGER NOT NULL, + is_httponly INTEGER NOT NULL, + samesite INTEGER NOT NULL, + source_scheme INTEGER NOT NULL DEFAULT 0, + source_port INTEGER NOT NULL DEFAULT -1, + last_update_utc INTEGER NOT NULL DEFAULT 0, + has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0, + UNIQUE(host_key, top_frame_site_key, name, path, source_scheme, source_port) + ) + `) + const insert = database.prepare(` + INSERT INTO cookies ( + creation_utc, + host_key, + top_frame_site_key, + name, + value, + encrypted_value, + path, + expires_utc, + is_secure, + is_httponly, + samesite, + source_scheme, + source_port, + last_update_utc, + has_cross_site_ancestor + ) VALUES (?, ?, '', ?, ?, ?, '/', 0, 0, 0, 0, 0, -1, ?, 0) + `) + rows.forEach((row, index) => { + insert.run( + 133_000_000_000_000 + index, + '.example.com', + row.name, + row.value, + row.encryptedValue ?? Buffer.alloc(0), + 0 + ) + }) + return database +} + +export function encryptMacChromiumCookie(value: string, password: string): Buffer { + const key = pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1') + const cipher = createCipheriv('aes-128-cbc', key, Buffer.alloc(16, ' ')) + return Buffer.concat([ + Buffer.from('v10'), + cipher.update(Buffer.from(value, 'latin1')), + cipher.final() + ]) +} diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index c2965e7f17f..1b4b0e1b1b7 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -1,11 +1,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' -const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({ +const { + appGetPathMock, + copyFileSyncMock, + execFileSyncMock, + sessionFromPartitionMock, + dialogShowOpenDialogMock +} = vi.hoisted(() => ({ + appGetPathMock: vi.fn(), + copyFileSyncMock: vi.fn(), + execFileSyncMock: vi.fn(), sessionFromPartitionMock: vi.fn(), dialogShowOpenDialogMock: vi.fn() })) +vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + copyFileSync: (...args: Parameters) => { + copyFileSyncMock(...args) + return actual.copyFileSync(...args) + } + } +}) + vi.mock('electron', () => ({ + app: { getPath: appGetPathMock }, BrowserWindow: { fromWebContents: vi.fn() }, dialog: { showOpenDialog: dialogShowOpenDialogMock }, session: { fromPartition: sessionFromPartitionMock } @@ -20,10 +43,26 @@ import { type ChromiumCookieColumnInfo, type DetectedBrowser } from './browser-cookie-import' -import { writeFileSync, mkdtempSync, rmSync } from 'node:fs' +import { + createChromiumCookieTestDatabase, + encryptMacChromiumCookie +} from './browser-cookie-import-test-database' +import { existsSync, writeFileSync, mkdtempSync, readdirSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' +function chromeBrowser(cookiesPath: string): DetectedBrowser { + return { + family: 'chrome', + label: 'Google Chrome', + cookiesPath, + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } +} + const LARGE_SAFARI_COOKIE_COUNT = 150_000 describe('summarizeCookieImportError', () => { @@ -327,6 +366,147 @@ describe('importCookiesFromBrowser Safari', () => { }) }) +describe('importCookiesFromBrowser Chromium', () => { + let tmpDir: string + let cookiesSetMock: ReturnType + let cookiesRemoveMock: ReturnType + let cookiesFlushStoreMock: ReturnType + let clearStorageDataMock: ReturnType + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-chromium-cookie-test-')) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) + cookiesFlushStoreMock = vi.fn().mockResolvedValue(undefined) + clearStorageDataMock = vi.fn().mockResolvedValue(undefined) + appGetPathMock.mockReset() + appGetPathMock.mockReturnValue(join(tmpDir, 'userData')) + copyFileSyncMock.mockClear() + execFileSyncMock.mockReset() + execFileSyncMock.mockImplementation(() => { + throw new Error('OS credential commands are unavailable in this test') + }) + sessionFromPartitionMock.mockReset() + sessionFromPartitionMock.mockReturnValue({ + cookies: { + set: cookiesSetMock, + remove: cookiesRemoveMock, + flushStore: cookiesFlushStoreMock + }, + clearStorageData: clearStorageDataMock + }) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('imports from a live Chromium source DB into a Network/Cookies target profile', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + // Why: keeping the WAL writer open proves import reads Chromium's live SQLite + // state instead of relying on a closed-file snapshot. + const sourceDb = createChromiumCookieTestDatabase( + sourceCookiesPath, + [{ name: 'sid', value: 'source-value' }], + { journalMode: 'wal' } + ) + createChromiumCookieTestDatabase(targetCookiesPath, [ + { name: 'old', value: 'target-value' } + ]).close() + + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + try { + expect(existsSync(`${sourceCookiesPath}-wal`)).toBe(true) + + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok).toBe(true) + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ + domain: '.example.com', + name: 'sid', + value: 'source-value' + }) + ) + expect(execFileSyncMock.mock.calls.some(([command]) => command === 'security')).toBe(false) + expect(copyFileSyncMock.mock.calls.some(([source]) => source === sourceCookiesPath)).toBe( + false + ) + expect(cookiesRemoveMock).not.toHaveBeenCalled() + expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] }) + } finally { + platformSpy.mockRestore() + sourceDb.close() + } + }) + + it('uses the OS key for encrypted Chromium rows', async () => { + const password = 'test-password' + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { + name: 'sid', + value: '', + encryptedValue: encryptMacChromiumCookie('encrypted-value', password) + } + ]).close() + createChromiumCookieTestDatabase(targetCookiesPath, []).close() + execFileSyncMock.mockImplementation((command: string) => { + if (command === 'security') { + return `${password}\n` + } + throw new Error(`Unexpected command: ${command}`) + }) + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok).toBe(true) + expect(execFileSyncMock).toHaveBeenCalledWith( + 'security', + expect.any(Array), + expect.any(Object) + ) + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'sid', value: 'encrypted-value' }) + ) + } finally { + platformSpy.mockRestore() + } + }) + + it('removes staging data when the OS key is unavailable', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { name: 'sid', value: '', encryptedValue: Buffer.from('v10-encrypted') } + ]).close() + createChromiumCookieTestDatabase(targetCookiesPath, []).close() + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok).toBe(false) + expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + } finally { + platformSpy.mockRestore() + } + }) +}) + describe('detectInstalledBrowsers', () => { it('returns an array of detected browsers', () => { const browsers = detectInstalledBrowsers() diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index bf568abfe79..efe2441b8a7 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -78,6 +78,7 @@ import type { } from '../../shared/types' import { browserSessionRegistry } from './browser-session-registry' import { setupClientHintsOverride } from './browser-session-ua' +import { resolveChromiumCookiesPath } from './chromium-cookie-path' // --------------------------------------------------------------------------- // Browser detection @@ -192,20 +193,6 @@ function browserRootPath(def: ChromiumBrowserDef): string | null { return join(configHome, def.linuxRoot) } -// Why: Chromium 96+ moved the cookies DB from /Cookies to -// /Network/Cookies. Try the newer path first, fall back to legacy. -function resolveCookiesPath(profileDir: string): string | null { - const networkPath = join(profileDir, 'Network', 'Cookies') - if (existsSync(networkPath)) { - return networkPath - } - const legacyPath = join(profileDir, 'Cookies') - if (existsSync(legacyPath)) { - return legacyPath - } - return null -} - function isSafeBrowserProfileDirectory(directory: string): boolean { return ( directory.length > 0 && @@ -373,7 +360,7 @@ export function detectInstalledBrowsers(): DetectedBrowser[] { // Use the first profile with a valid cookies path as the default selection. for (const profile of profiles) { const profileDir = join(root, profile.directory) - const cookiesPath = resolveCookiesPath(profileDir) + const cookiesPath = resolveChromiumCookiesPath(profileDir) if (cookiesPath) { detected.push({ family: browser.family, @@ -434,7 +421,7 @@ export function selectBrowserProfile( return null } const profileDir = join(root, profileDirectory) - const cookiesPath = resolveCookiesPath(profileDir) + const cookiesPath = resolveChromiumCookiesPath(profileDir) if (!cookiesPath) { return null } @@ -1468,36 +1455,8 @@ export async function importCookiesFromBrowser( return importCookiesFromSafari(browser, targetPartition) } - // Why: the browser may hold a lock on the Cookies file. Copying to a temp - // location avoids lock contention and ensures we read a consistent snapshot. - const tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-import-')) - const tmpCookiesPath = join(tmpDir, 'Cookies') - - try { - copyFileSync(browser.cookiesPath, tmpCookiesPath) - // Why: when the source browser is running, it uses WAL journal mode. The most - // recently written cookies (including fresh auth tokens) may only exist in the - // WAL sidecar file, not yet flushed to the main DB. Copying WAL + SHM ensures - // our snapshot reflects the browser's current state. - for (const suffix of ['-wal', '-shm'] as const) { - const sidecar = browser.cookiesPath + suffix - if (existsSync(sidecar)) { - try { - copyFileSync(sidecar, tmpCookiesPath + suffix) - } catch { - // Why: sidecar copy is best-effort. The main DB alone may still have - // enough cookies for a usable session; missing the WAL just means - // we might miss the very latest writes. - } - } - } - } catch { - rmSync(tmpDir, { recursive: true, force: true }) - return { - ok: false, - reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` - } - } + // Why: SQLite can coordinate with Chromium's live WAL locks without a pre-copy. + const sourceCookiesPath = browser.cookiesPath // Why: Electron's cookies.set() API rejects many valid cookie values (binary // bytes > 0x7F etc). Instead, decrypt from the source browser and write @@ -1508,16 +1467,6 @@ export async function importCookiesFromBrowser( // In packaged builds where os_crypt IS active, CookieMonster will re-encrypt // plaintext cookies on its next flush, so this approach is safe in both modes. - // Why: only Chromium browsers reach this point — Firefox/Safari dispatched above. - const sourceKey = getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) - if (!sourceKey) { - rmSync(tmpDir, { recursive: true, force: true }) - return { - ok: false, - reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` - } - } - // Why: CookieMonster holds the live DB's data in memory and overwrites it // on flush/shutdown. Writing directly to the live DB is futile. Instead, // copy the live DB to a staging location, populate it there, and let the @@ -1526,13 +1475,14 @@ export async function importCookiesFromBrowser( await targetSession.cookies.flushStore() const partitionName = targetPartition.replace('persist:', '') - const liveCookiesPath = join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies') + const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName) + let liveCookiesPath = resolveChromiumCookiesPath(partitionDir) // Why: Electron only creates the partition's Cookies SQLite file after the // session has actually stored a cookie. For newly created profiles that have // never been used by a webview, the file won't exist yet. Setting and // removing a throwaway cookie forces Electron to initialize the database. - if (!existsSync(liveCookiesPath)) { + if (!liveCookiesPath) { try { await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' }) await targetSession.cookies.remove('https://localhost', '__init') @@ -1540,10 +1490,10 @@ export async function importCookiesFromBrowser( } catch { // ignore — the set/remove may fail but flushStore should still create the file } + liveCookiesPath = resolveChromiumCookiesPath(partitionDir) } - if (!existsSync(liveCookiesPath)) { - rmSync(tmpDir, { recursive: true, force: true }) + if (!liveCookiesPath) { return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } } @@ -1557,7 +1507,6 @@ export async function importCookiesFromBrowser( mkdirSync(stagingDir, { recursive: true }) copyFileSync(liveCookiesPath, stagingCookiesPath) } catch { - rmSync(tmpDir, { recursive: true, force: true }) return { ok: false, reason: 'Could not create staging cookie database.' } } @@ -1567,7 +1516,7 @@ export async function importCookiesFromBrowser( try { // Why: Chromium stores timestamps as microseconds since 1601, which can exceed // Number.MAX_SAFE_INTEGER (~9e15). readBigInts ensures no precision loss. - sourceDb = new DatabaseSync(tmpCookiesPath, { readOnly: true, readBigInts: true }) + sourceDb = new DatabaseSync(sourceCookiesPath, { readOnly: true, readBigInts: true }) stagingDb = new DatabaseSync(stagingCookiesPath) const targetColumnInfo = stagingDb @@ -1590,10 +1539,37 @@ export async function importCookiesFromBrowser( if (sourceRows.length === 0) { stagingDb.close() stagingDb = null - rmSync(tmpDir, { recursive: true, force: true }) + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } return { ok: false, reason: `No cookies found in ${browser.label}.` } } + const needsSourceKey = sourceRows.some((sourceRow) => { + const encRaw = sourceRow.encrypted_value + return encRaw instanceof Uint8Array && encRaw.length > 0 + }) + const sourceKey = needsSourceKey + ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) + : null + if (needsSourceKey && !sourceKey) { + stagingDb.close() + stagingDb = null + // Why: key denial happens after staging, so failed retries must not leave + // one full target database copy behind each time. + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + return { + ok: false, + reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` + } + } + // Why: Google's integrity cookies (SIDCC, __Secure-*PSIDCC, __Secure-STRP) // are cryptographically bound to the source browser's TLS fingerprint and // environment. Importing them into a different browser causes @@ -1652,7 +1628,7 @@ export async function importCookiesFromBrowser( let decryptedValue: Buffer if (encBuf && encBuf.length > 0) { - const raw = decryptCookieValueRaw(encBuf, sourceKey) + const raw = sourceKey ? decryptCookieValueRaw(encBuf, sourceKey) : null if (!raw) { skipped++ continue @@ -1709,7 +1685,6 @@ export async function importCookiesFromBrowser( stagingDb.close() stagingDb = null - rmSync(tmpDir, { recursive: true, force: true }) diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`) // Why: clearing the session's in-memory cookie store before loading imported @@ -1795,7 +1770,6 @@ export async function importCookiesFromBrowser( } catch { /* may already be closed */ } - rmSync(tmpDir, { recursive: true, force: true }) // Why: if the import fails after the staging DB was created, clean it up // to avoid a stale staged import being applied on the next cold start. try { diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 8fc3ea18b6b..583afff0815 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -157,6 +157,31 @@ describe('BrowserSessionRegistry persistence', () => { expect(fsState.present.has('/user-data/Partitions/orca-browser/Cookies')).toBe(true) }) + it('replays pending cookies into an existing Network database', async () => { + const stagedPath = '/staged/network-import' + const networkPath = '/user-data/Partitions/orca-browser/Network/Cookies' + const legacyPath = '/user-data/Partitions/orca-browser/Cookies' + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + userAgent: null, + pendingCookieDbPath: stagedPath, + profiles: [] + }) + fsState.files.set(stagedPath, 'imported cookies') + fsState.files.set(networkPath, 'old cookies') + fsState.present.add(stagedPath) + fsState.present.add(networkPath) + + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.applyPendingCookieImport() + + expect(fsState.files.get(networkPath)).toBe('imported cookies') + expect(fsState.present.has(legacyPath)).toBe(false) + }) + it('persists new browser session profiles under the active Orca profile directory', async () => { const fsState = createFsState() const profileMetaPath = '/user-data/profiles/local-work/browser-session-meta.json' diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index cc974ff7a1a..3603de11568 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -26,6 +26,7 @@ import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../sh import { browserManager } from './browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' +import { resolveChromiumCookiesPath } from './chromium-cookie-path' import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy' import { allowsBrowserWebAuthnPermission, @@ -101,7 +102,10 @@ class BrowserSessionRegistry { private static partitionCookiesPath(partition: string): string { const partitionName = partition.replace('persist:', '') - return join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies') + const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName) + // Why: replay must overwrite the same modern or legacy database that the + // importing Electron partition already uses. + return resolveChromiumCookiesPath(partitionDir) ?? join(partitionDir, 'Cookies') } // Why: write-to-temp-then-rename is atomic on all supported platforms. diff --git a/src/main/browser/chromium-cookie-path.ts b/src/main/browser/chromium-cookie-path.ts new file mode 100644 index 00000000000..ed6b2c4a8c6 --- /dev/null +++ b/src/main/browser/chromium-cookie-path.ts @@ -0,0 +1,13 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +// Why: Chromium 96+ moved the cookie DB under Network, while older profiles +// still use the profile root. Every reader and replay writer must agree. +export function resolveChromiumCookiesPath(profileDir: string): string | null { + const networkPath = join(profileDir, 'Network', 'Cookies') + if (existsSync(networkPath)) { + return networkPath + } + const legacyPath = join(profileDir, 'Cookies') + return existsSync(legacyPath) ? legacyPath : null +} diff --git a/src/main/orca-profiles/profile-cloud-dev-auth.ts b/src/main/orca-profiles/profile-cloud-dev-auth.ts index 2383cea6d40..137696e17ce 100644 --- a/src/main/orca-profiles/profile-cloud-dev-auth.ts +++ b/src/main/orca-profiles/profile-cloud-dev-auth.ts @@ -1,8 +1,5 @@ import { randomUUID } from 'node:crypto' -import type { - OrcaCloudCapabilities, - OrcaCloudOrgSummary -} from '../../shared/orca-profiles' +import type { OrcaCloudCapabilities, OrcaCloudOrgSummary } from '../../shared/orca-profiles' import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' const DEV_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 diff --git a/src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts b/src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts index 045f31359f8..9db1d830986 100644 --- a/src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts +++ b/src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts @@ -138,9 +138,7 @@ describe('Orca cloud profile auth-failure retry', () => { safeStorageMock.encryptString.mockReset() safeStorageMock.isEncryptionAvailable.mockReset() safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8')) - safeStorageMock.encryptString.mockImplementation((value: string) => - Buffer.from(value, 'utf-8') - ) + safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8')) safeStorageMock.isEncryptionAvailable.mockReturnValue(true) vi.unstubAllEnvs() vi.stubEnv('ORCA_CLOUD_API_URL', '') diff --git a/src/main/orca-profiles/profile-cloud-service-refresh.test.ts b/src/main/orca-profiles/profile-cloud-service-refresh.test.ts index e2e8fcbc1d4..075573eea3a 100644 --- a/src/main/orca-profiles/profile-cloud-service-refresh.test.ts +++ b/src/main/orca-profiles/profile-cloud-service-refresh.test.ts @@ -123,9 +123,7 @@ describe('Orca cloud profile service session refresh', () => { safeStorageMock.encryptString.mockReset() safeStorageMock.isEncryptionAvailable.mockReset() safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8')) - safeStorageMock.encryptString.mockImplementation((value: string) => - Buffer.from(value, 'utf-8') - ) + safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8')) safeStorageMock.isEncryptionAvailable.mockReturnValue(true) vi.unstubAllEnvs() vi.stubEnv('ORCA_CLOUD_API_URL', '') diff --git a/src/main/orca-profiles/profile-cloud-service.test.ts b/src/main/orca-profiles/profile-cloud-service.test.ts index 92e0064c2f5..552a2ee8ea1 100644 --- a/src/main/orca-profiles/profile-cloud-service.test.ts +++ b/src/main/orca-profiles/profile-cloud-service.test.ts @@ -114,9 +114,7 @@ describe('Orca cloud profile service', () => { safeStorageMock.encryptString.mockReset() safeStorageMock.isEncryptionAvailable.mockReset() safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8')) - safeStorageMock.encryptString.mockImplementation((value: string) => - Buffer.from(value, 'utf-8') - ) + safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8')) safeStorageMock.isEncryptionAvailable.mockReturnValue(true) revokeOrcaCloudSessionMock.mockResolvedValue(undefined) vi.unstubAllEnvs() From 5c6cbfc84e1eb1db6f739b4cc31c819deb2c4c2b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:13:11 -0700 Subject: [PATCH 07/17] fix(speech): harden offline STT chunk decode warm-reuse lifecycle (#8007) Keep offline multi-chunk decoding safe for warm workers: refresh streams after every decode attempt, continue sibling chunks after a failure, reset session state on stop without dropping the stopped signal, and drop in-flight feeds while stop is in progress so residual audio cannot contaminate the next dictation. Extract model path/hotwords config to stay under max-lines. --- src/main/speech/stt-service.test.ts | 20 +++ src/main/speech/stt-service.ts | 170 +++++++++++---------- src/main/speech/stt-worker-model-config.ts | 80 ++++++++++ src/main/speech/stt-worker.ts | 129 ++++++---------- 4 files changed, 239 insertions(+), 160 deletions(-) create mode 100644 src/main/speech/stt-worker-model-config.ts diff --git a/src/main/speech/stt-service.test.ts b/src/main/speech/stt-service.test.ts index 5138831d8ae..31736082ab8 100644 --- a/src/main/speech/stt-service.test.ts +++ b/src/main/speech/stt-service.test.ts @@ -239,6 +239,26 @@ describe('SttService', () => { expect(worker!.messages.filter((message) => message.type === 'feed')).toHaveLength(0) }) + it('drops in-flight audio while stop is waiting for the worker', async () => { + const service = new SttService({ + getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }), + getModelDir: vi.fn().mockReturnValue('/tmp/model-a') + } as never) + + await service.startDictation('model-a', vi.fn(), undefined, 'desktop:1') + const worker = getLastWorker() + expect(worker).toBeDefined() + worker!.emitStoppedOnStop = false + + const stopPromise = service.stopDictation('desktop:1') + await Promise.resolve() + service.feedAudio(new Float32Array([1]), 16000, 'desktop:1') + expect(worker!.messages.filter((message) => message.type === 'feed')).toHaveLength(0) + + worker!.emit('message', { type: 'stopped' }) + await stopPromise + }) + it('rejects deletion prep while the target model is starting', async () => { let resolveModelState: (state: { id: string; status: string }) => void = () => {} const modelStatePromise = new Promise<{ id: string; status: string }>((resolve) => { diff --git a/src/main/speech/stt-service.ts b/src/main/speech/stt-service.ts index d9d2129dd71..6974dd5945e 100644 --- a/src/main/speech/stt-service.ts +++ b/src/main/speech/stt-service.ts @@ -35,6 +35,10 @@ export class SttService { private canceledOwners = new Set() private eventSink: SttEventSink | null = null private idleTeardownTimer: NodeJS.Timeout | null = null + // Why: stop resolves only after the worker flushes; in-flight feedAudio IPC + // must not enqueue samples after that flush or they stick on the warm worker + // and contaminate the next dictation session. + private stopping = false // Why: warm workers intentionally keep lifecycle listeners while reusable; // stale workers must not retain this service after error, exit, or teardown. private cleanupWorkerLifecycleListeners: (() => void) | null = null @@ -259,6 +263,9 @@ export class SttService { } feedAudio(samples: Float32Array, sampleRate: number, owner = 'desktop'): void { + if (this.stopping) { + return + } const currentOwner = this.activeOwner ?? this.startingOwner if (!currentOwner) { return @@ -288,92 +295,97 @@ export class SttService { throw new Error('dictation_owner_mismatch') } - if (this.cloudSession) { - const session = this.cloudSession - this.cloudSession = null - try { - const text = await session.finish() - if (text) { - this.eventSink?.({ type: 'final', text }) + this.stopping = true + try { + if (this.cloudSession) { + const session = this.cloudSession + this.cloudSession = null + try { + const text = await session.finish() + if (text) { + this.eventSink?.({ type: 'final', text }) + } + } catch (error) { + this.eventSink?.({ + type: 'error', + error: error instanceof Error ? error.message : String(error) + }) + } finally { + this.eventSink?.({ type: 'stopped' }) + this.activeModelId = null + this.activeHotwordsFilePath = undefined + this.activeOwner = null + this.eventSink = null } - } catch (error) { - this.eventSink?.({ - type: 'error', - error: error instanceof Error ? error.message : String(error) - }) - } finally { - this.eventSink?.({ type: 'stopped' }) - this.activeModelId = null - this.activeHotwordsFilePath = undefined - this.activeOwner = null - this.eventSink = null - } - return - } - - const worker = this.worker - if (!worker) { - return - } - worker.postMessage({ type: 'stop' }) - - let forcedTeardown = false - await new Promise((resolve) => { - let settled = false - let timeout: ReturnType | null = null - - const cleanup = (): void => { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - worker.off('message', onStopped) + return } - const finish = (): void => { - if (settled) { - return - } - settled = true - cleanup() - resolve() + const worker = this.worker + if (!worker) { + return } + worker.postMessage({ type: 'stop' }) - const onStopped = (msg: { type: string; text?: string; error?: string }) => { - if (msg.type === 'stopped') { - finish() + let forcedTeardown = false + await new Promise((resolve) => { + let settled = false + let timeout: ReturnType | null = null + + const cleanup = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + worker.off('message', onStopped) + } + + const finish = (): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve() + } + + const onStopped = (msg: { type: string; text?: string; error?: string }) => { + if (msg.type === 'stopped') { + finish() + } + } + + timeout = setTimeout(() => { + if (settled) { + return + } + settled = true + forcedTeardown = true + cleanup() + // Why: a worker that cannot finish dictation is no longer reusable; do + // not keep it in the warm-worker slot or retain its message listeners. + this.cleanupActiveWorkerLifecycleListeners() + worker.removeAllListeners() + void worker.terminate().finally(resolve) + }, STOP_DICTATION_TIMEOUT_MS) + + worker.on('message', onStopped) + }) + + if (this.worker === worker) { + if (forcedTeardown) { + this.worker = null + this.activeModelId = null + this.activeHotwordsFilePath = undefined + this.activeOwner = null + this.eventSink = null + } else { + this.activeOwner = null + this.eventSink = null + this.scheduleIdleTeardown() } } - - timeout = setTimeout(() => { - if (settled) { - return - } - settled = true - forcedTeardown = true - cleanup() - // Why: a worker that cannot finish dictation is no longer reusable; do - // not keep it in the warm-worker slot or retain its message listeners. - this.cleanupActiveWorkerLifecycleListeners() - worker.removeAllListeners() - void worker.terminate().finally(resolve) - }, STOP_DICTATION_TIMEOUT_MS) - - worker.on('message', onStopped) - }) - - if (this.worker === worker) { - if (forcedTeardown) { - this.worker = null - this.activeModelId = null - this.activeHotwordsFilePath = undefined - this.activeOwner = null - this.eventSink = null - } else { - this.activeOwner = null - this.eventSink = null - this.scheduleIdleTeardown() - } + } finally { + this.stopping = false } } diff --git a/src/main/speech/stt-worker-model-config.ts b/src/main/speech/stt-worker-model-config.ts new file mode 100644 index 00000000000..6a2a0e89dd9 --- /dev/null +++ b/src/main/speech/stt-worker-model-config.ts @@ -0,0 +1,80 @@ +import { readdirSync } from 'node:fs' + +// Why: different models name their ONNX files differently (e.g. +// encoder.int8.onnx vs tiny-encoder.onnx vs encoder-epoch-99-avg-1.onnx). +// We resolve the actual path from the manifest's files list by searching +// for the role name anywhere in the filename. +export function resolveFile( + files: string[], + role: string, + modelDir: string, + ext = '.onnx' +): string { + const match = files.find((f) => f.includes(role) && f.endsWith(ext)) + if (!match) { + throw new Error(`No *${role}*${ext} found in model files: ${files.join(', ')}`) + } + return `${modelDir}/${match}` +} + +export function resolveTokens(files: string[], modelDir: string): string { + const match = files.find((f) => f.endsWith('tokens.txt')) + if (!match) { + throw new Error(`No *tokens.txt found in model files: ${files.join(', ')}`) + } + return `${modelDir}/${match}` +} + +// Why: BPE models need a vocab file for hotwords token matching. The file +// ships in the model archive but isn't listed in the manifest. We discover +// it at runtime to avoid breaking existing downloads. +function discoverBpeVocab(modelDir: string): string | undefined { + try { + const entries = readdirSync(modelDir) + const vocabFile = entries.find((f) => f.endsWith('.vocab')) + return vocabFile ? `${modelDir}/${vocabFile}` : undefined + } catch { + return undefined + } +} + +export type HotwordsConfig = { + decodingMethod: string + hotwordsFile?: string + hotwordsScore?: number + modelingUnit?: string + bpeVocab?: string +} + +export function buildHotwordsConfig(opts: { + modelDir: string + modelType: string + hotwordsFilePath?: string + modelingUnit?: string +}): HotwordsConfig { + if (opts.modelType !== 'transducer' || !opts.hotwordsFilePath) { + return { decodingMethod: 'greedy_search' } + } + + const unit = opts.modelingUnit + if (unit?.includes('bpe')) { + const bpeVocab = discoverBpeVocab(opts.modelDir) + if (!bpeVocab) { + return { decodingMethod: 'greedy_search' } + } + return { + decodingMethod: 'modified_beam_search', + hotwordsFile: opts.hotwordsFilePath, + hotwordsScore: 1.5, + modelingUnit: unit, + bpeVocab + } + } + + return { + decodingMethod: 'modified_beam_search', + hotwordsFile: opts.hotwordsFilePath, + hotwordsScore: 1.5, + modelingUnit: unit + } +} diff --git a/src/main/speech/stt-worker.ts b/src/main/speech/stt-worker.ts index 2f4f9b025d4..1e6cd8bb75c 100644 --- a/src/main/speech/stt-worker.ts +++ b/src/main/speech/stt-worker.ts @@ -1,8 +1,8 @@ /* oxlint-disable typescript-eslint/no-explicit-any -- sherpa-onnx native addon has no type definitions */ import { parentPort, workerData } from 'node:worker_threads' -import { readdirSync } from 'node:fs' import { resampleToRate } from './stt-audio-resample' import { OfflineAudioChunker } from './stt-offline-audio-chunker' +import { buildHotwordsConfig, resolveFile, resolveTokens } from './stt-worker-model-config' type WorkerMessage = | { @@ -39,73 +39,6 @@ function loadSherpa(): any { return require(modulePath) } -// Why: different models name their ONNX files differently (e.g. -// encoder.int8.onnx vs tiny-encoder.onnx vs encoder-epoch-99-avg-1.onnx). -// We resolve the actual path from the manifest's files list by searching -// for the role name anywhere in the filename. -function resolveFile(files: string[], role: string, modelDir: string, ext = '.onnx'): string { - const match = files.find((f) => f.includes(role) && f.endsWith(ext)) - if (!match) { - throw new Error(`No *${role}*${ext} found in model files: ${files.join(', ')}`) - } - return `${modelDir}/${match}` -} - -function resolveTokens(files: string[], modelDir: string): string { - const match = files.find((f) => f.endsWith('tokens.txt')) - if (!match) { - throw new Error(`No *tokens.txt found in model files: ${files.join(', ')}`) - } - return `${modelDir}/${match}` -} - -// Why: BPE models need a vocab file for hotwords token matching. The file -// ships in the model archive but isn't listed in the manifest. We discover -// it at runtime to avoid breaking existing downloads. -function discoverBpeVocab(modelDir: string): string | undefined { - try { - const entries = readdirSync(modelDir) - const vocabFile = entries.find((f) => f.endsWith('.vocab')) - return vocabFile ? `${modelDir}/${vocabFile}` : undefined - } catch { - return undefined - } -} - -function buildHotwordsConfig(msg: Extract): { - decodingMethod: string - hotwordsFile?: string - hotwordsScore?: number - modelingUnit?: string - bpeVocab?: string -} { - if (msg.modelType !== 'transducer' || !msg.hotwordsFilePath) { - return { decodingMethod: 'greedy_search' } - } - - const unit = msg.modelingUnit - if (unit?.includes('bpe')) { - const bpeVocab = discoverBpeVocab(msg.modelDir) - if (!bpeVocab) { - return { decodingMethod: 'greedy_search' } - } - return { - decodingMethod: 'modified_beam_search', - hotwordsFile: msg.hotwordsFilePath, - hotwordsScore: 1.5, - modelingUnit: unit, - bpeVocab - } - } - - return { - decodingMethod: 'modified_beam_search', - hotwordsFile: msg.hotwordsFilePath, - hotwordsScore: 1.5, - modelingUnit: unit - } -} - function handleInit(msg: Extract): void { try { sherpa = loadSherpa() @@ -204,15 +137,33 @@ function handleInit(msg: Extract): void { } } -// Why: an offline stream is single-use — decode one bounded chunk, then -// recreate the stream so the recognizer is ready for the next chunk. +// Why: an offline stream is single-use — always mint a fresh stream after an +// attempt so a failed decode cannot leave a spent stream on a warm worker. function decodeOfflineChunk(samples: Float32Array): string { - sherpa.acceptWaveformOffline(stream, { sampleRate: offlineSampleRate, samples }) - sherpa.decodeOfflineStream(recognizer, stream) - const resultJson = sherpa.getOfflineStreamResultAsJson(stream) - stream = sherpa.createOfflineStream(recognizer) - const result = JSON.parse(resultJson) - return result?.text?.trim() ?? '' + try { + sherpa.acceptWaveformOffline(stream, { sampleRate: offlineSampleRate, samples }) + sherpa.decodeOfflineStream(recognizer, stream) + const resultJson = sherpa.getOfflineStreamResultAsJson(stream) + const result = JSON.parse(resultJson) + return result?.text?.trim() ?? '' + } finally { + if (sherpa && recognizer) { + stream = sherpa.createOfflineStream(recognizer) + } + } +} + +// Why: warm-worker reuse must not see residual audio or a spent offline stream. +// Recovery failures are swallowed so they cannot skip the stopped lifecycle signal. +function resetOfflineSessionState(): void { + try { + offlineChunker = new OfflineAudioChunker(offlineSampleRate) + if (sherpa && recognizer) { + stream = sherpa.createOfflineStream(recognizer) + } + } catch { + // Non-fatal: prefer posting stopped over stranding stopDictation for 60s. + } } function handleFeed(msg: Extract): void { @@ -253,12 +204,23 @@ function handleFeed(msg: Extract): void { // the whole app (#7925). Decode bounded chunks as they fill instead; // each consumer already appends multiple 'final' segments per session. const readyChunks = offlineChunker?.push(new Float32Array(samples)) ?? [] + // Why: keep decoding later ready windows after one failure — push() has + // already removed them from the chunker, and decodeOfflineChunk refreshes + // the stream in finally so a spent stream cannot poison the next attempt. + let firstError: unknown = null for (const chunk of readyChunks) { - const text = decodeOfflineChunk(chunk) - if (text) { - parentPort?.postMessage({ type: 'final', text }) + try { + const text = decodeOfflineChunk(chunk) + if (text) { + parentPort?.postMessage({ type: 'final', text }) + } + } catch (err) { + firstError ??= err } } + if (firstError) { + throw firstError + } } } catch (err) { parentPort?.postMessage({ type: 'error', error: String(err) }) @@ -294,12 +256,17 @@ function handleStop(): void { parentPort?.postMessage({ type: 'final', text }) } } + resetOfflineSessionState() } } catch (err) { + if (!isStreaming) { + resetOfflineSessionState() + } parentPort?.postMessage({ type: 'error', error: String(err) }) + } finally { + // Why: stopDictation waits on this signal; recovery must never prevent it. + parentPort?.postMessage({ type: 'stopped' }) } - - parentPort?.postMessage({ type: 'stopped' }) } function handleTeardown(): void { From 9eff84d6747f34be5183bfcd8515178c25b7c691 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:22:35 -0700 Subject: [PATCH 08/17] perf(source-control): virtualize the changed-files list (#7619) The Source Control panel mounted every non-collapsed file row, so large changesets (big refactors, lockfiles, branch compare with hundreds of entries) created hundreds to thousands of DOM rows and re-rendered them all on every 3s git-status refresh (`STA-351`, `STA-1280`). Window each per-section list (uncommitted tree/list, committed-on-branch tree/list) with @tanstack/react-virtual against the panel's shared scroller, extracted into source-control-virtual-file-list.tsx. Sections under 50 rows keep the exact pre-virtualization DOM so small changesets see zero change; larger sections mount only viewport + overscan rows. Fixed-height estimates with element measurement keep identical refreshes from moving the scroll position, and stable row keys carry item identity across status polls. Co-authored-by: Orca Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../right-sidebar/SourceControl.tsx | 54 ++- .../SourceControl.virtual-file-list.test.tsx | 430 ++++++++++++++++++ .../source-control-virtual-file-list.tsx | 106 +++++ 3 files changed, 580 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/source-control-virtual-file-list.tsx diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index aef619545ea..6a863e9a36d 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -109,6 +109,7 @@ import { type SourceControlEntryGroups, type SourceControlSectionArea } from './source-control-section-order' +import { SourceControlVirtualFileList } from './source-control-virtual-file-list' import { buildActiveOpenFileSignature, buildActiveOpenRowKeys @@ -799,6 +800,10 @@ export function clearRemoteActionErrorsForCompletedConflictOperations({ function SourceControlInner(): React.JSX.Element { const sourceControlRef = useRef(null) + // Why: the changed-file sections virtualize against the panel's shared + // scroller rather than owning a nested scroll container. State (not a ref) + // so the lists re-render and start observing once the element attaches. + const [fileListScrollElement, setFileListScrollElement] = useState(null) const isMac = useMemo(() => navigator.userAgent.includes('Mac'), []) const pendingCommentEditorRevealFrameIdsRef = useRef([]) // Why: React setState is async, so a rapid double-click on the Commit @@ -5708,6 +5713,7 @@ function SourceControlInner(): React.JSX.Element { )}
0 ? 50 : undefined }} > @@ -6042,8 +6048,12 @@ function SourceControlInner(): React.JSX.Element { } /> {!isCollapsed && - (sourceControlViewMode === 'tree' - ? (visibleTreeRowsBySection[id] ?? []).map((node) => { + (sourceControlViewMode === 'tree' ? ( + node.key} + renderRow={(node) => { if (node.type === 'submodule-placeholder') { return ( ) - }) - : (visibleListRowsBySection[id] ?? []).map((row) => { + }} + /> + ) : ( + + row.type === 'submodule-placeholder' + ? row.key + : `${row.entry.area}::${row.entry.path}` + } + renderRow={(row) => { if (row.type === 'submodule-placeholder') { return ( ) - }))} + }} + /> + ))}
) })} @@ -6202,8 +6224,12 @@ function SourceControlInner(): React.JSX.Element { } /> {!collapsedSections.has('branch') && - (sourceControlViewMode === 'tree' - ? visibleBranchTreeRows.map((node) => { + (sourceControlViewMode === 'tree' ? ( + node.key} + renderRow={(node) => { if (node.type === 'directory') { return ( ) - }) - : filteredBranchEntries.map((entry) => ( + }} + /> + ) : ( + `branch:${entry.path}`} + renderRow={(entry) => ( openCommittedDiff(entry, event)} commentCount={diffCommentCountByPath.get(entry.path) ?? 0} /> - )))} + )} + /> + ))} )} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx new file mode 100644 index 00000000000..2dc5f0bc474 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx @@ -0,0 +1,430 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { GitStatusEntry } from '../../../../shared/types' +import SourceControl from './SourceControl' +import { + SOURCE_CONTROL_FILE_ROW_HEIGHT_PX, + SOURCE_CONTROL_FILE_ROW_OVERSCAN, + SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS +} from './source-control-virtual-file-list' + +const mocks = vi.hoisted(() => { + const activeRepo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 0 + } + const activeWorktree = { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt', + head: 'abcdef123', + branch: 'refs/heads/feature/virtual-list', + isBare: false, + isMainWorktree: false, + displayName: 'feature/virtual-list', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + return { + activeRepo, + activeWorktree, + state: {} as Record + } +}) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector?: (state: Record) => unknown) => + selector ? selector(mocks.state) : mocks.state, + { + getState: () => mocks.state + } + ) + return { useAppStore } +}) + +vi.mock('@/store/selectors', () => ({ + useActiveWorktree: () => mocks.activeWorktree, + useRepoById: (repoId: string | null) => + repoId === mocks.activeRepo.id ? mocks.activeRepo : null, + useWorktreeMap: () => new Map([[mocks.activeWorktree.id, mocks.activeWorktree]]) +})) + +vi.mock('@/components/confirmation-dialog', () => ({ + useConfirmationDialog: () => vi.fn().mockResolvedValue(true) +})) + +vi.mock('./git-status-refresh', () => ({ + refreshGitStatusForWorktree: vi.fn().mockResolvedValue(undefined) +})) + +const VIEWPORT_HEIGHT_PX = 600 +// Rows the viewport can show plus overscan on both edges plus the partial +// rows clipped at each edge of the window. +const MAX_MOUNTED_ROWS = + Math.ceil(VIEWPORT_HEIGHT_PX / SOURCE_CONTROL_FILE_ROW_HEIGHT_PX) + + 2 * SOURCE_CONTROL_FILE_ROW_OVERSCAN + + 2 + +function gitEntry(overrides: Partial): GitStatusEntry { + return { + path: 'src/file.ts', + area: 'unstaged', + status: 'modified', + added: 1, + removed: 0, + ...overrides + } +} + +function manyEntries(count: number): GitStatusEntry[] { + return Array.from({ length: count }, (_, index) => + gitEntry({ path: `src/file-${String(index).padStart(3, '0')}.ts` }) + ) +} + +function noopAsync(value: unknown = undefined): () => Promise { + return vi.fn().mockResolvedValue(value) +} + +function resetState(overrides: Partial> = {}): void { + vi.clearAllMocks() + mocks.state = { + activeWorktreeId: mocks.activeWorktree.id, + activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' }, + groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] }, + repos: [mocks.activeRepo], + worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] }, + rightSidebarOpen: false, + rightSidebarTab: 'source-control', + gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, + gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, + gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null }, + gitConflictOperationByWorktree: {}, + remoteStatusesByWorktree: {}, + isRemoteOperationActive: false, + inFlightRemoteOpKind: null, + settings: null, + hostedReviewCache: {}, + prCache: {}, + commitMessageGenerationRecords: {}, + pullRequestGenerationRecords: {}, + openFiles: [], + activeFileIdByWorktree: {}, + activeTabTypeByWorktree: {}, + getDiffComments: vi.fn(() => []), + updateSettings: noopAsync(), + openSettingsTarget: vi.fn(), + openSettingsPage: vi.fn(), + fetchHostedReviewForBranch: noopAsync(), + getHostedReviewCreationEligibility: noopAsync(null), + createHostedReview: noopAsync({ ok: false, error: 'not available' }), + updateWorktreeMeta: noopAsync(), + fetchPRForBranch: noopAsync(), + enqueueGitHubPRRefresh: vi.fn(), + updateRepo: noopAsync(), + setGitStatus: vi.fn(), + updateWorktreeGitIdentity: vi.fn(), + beginGitBranchCompareRequest: vi.fn(() => 'request-key'), + setGitBranchCompareResult: vi.fn(), + clearGitBranchCompare: vi.fn(), + fetchUpstreamStatus: noopAsync(), + setUpstreamStatus: vi.fn(), + pushBranch: noopAsync(), + pullBranch: noopAsync(), + fastForwardBranch: noopAsync(), + syncBranch: noopAsync(), + rebaseFromBase: noopAsync(), + fetchBranch: noopAsync(), + revealInExplorer: vi.fn(), + trackConflictPath: vi.fn(), + openDiff: vi.fn(), + openFile: vi.fn(), + setEditorViewMode: vi.fn(), + setMarkdownViewMode: vi.fn(), + setPendingEditorReveal: vi.fn(), + openConflictFile: vi.fn(), + openConflictReview: vi.fn(), + openBranchDiff: vi.fn(), + createEmptySplitGroup: vi.fn(() => 'group-2'), + openAllDiffs: vi.fn(), + openBranchAllDiffs: vi.fn(), + openCommitAllDiffs: vi.fn(), + deleteDiffComment: noopAsync(true), + clearDiffComments: noopAsync(true), + clearDiffCommentsForFile: noopAsync(true), + setScrollToDiffCommentId: vi.fn(), + setRightSidebarOpen: vi.fn(), + setRightSidebarTab: vi.fn(), + allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'), + setCommitMessageGenerationRecord: vi.fn(), + updateCommitMessageGenerationRecord: vi.fn(), + pruneCommitMessageGenerationRecords: vi.fn(), + allocatePullRequestGenerationRequestId: vi.fn(() => 'pr-generation-1'), + setPullRequestGenerationRecord: vi.fn(), + updatePullRequestGenerationRecord: vi.fn(), + prunePullRequestGenerationRecords: vi.fn(), + ...overrides + } +} + +let container: HTMLDivElement +let root: Root + +class NoopResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + resetState() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + // Why: happy-dom has no layout. Give the virtualizer a 600px scroll viewport + // and 24px rows (both rect + row measurement read offsetHeight), and keep + // the observer path inert so measurements stay deterministic. + vi.stubGlobal('ResizeObserver', NoopResizeObserver) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation( + function (this: HTMLElement) { + return this.classList.contains('overflow-auto') + ? VIEWPORT_HEIGHT_PX + : SOURCE_CONTROL_FILE_ROW_HEIGHT_PX + } + ) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + // The panel scroller acts as the fixed viewport; everything inside it + // shifts up by scrollTop, which is what the list's scroll-margin math + // reads. Leaf rows only ever contribute their 24px height. + const isScroller = this.classList.contains('overflow-auto') + const scroller = isScroller ? null : this.closest('.overflow-auto') + const top = isScroller ? 0 : -(scroller?.scrollTop ?? 0) + return { + top, + bottom: top + SOURCE_CONTROL_FILE_ROW_HEIGHT_PX, + height: SOURCE_CONTROL_FILE_ROW_HEIGHT_PX, + left: 0, + right: 240, + width: 240, + x: 0, + y: top, + toJSON: () => ({}) + } as DOMRect + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +function renderSourceControl(): void { + act(() => { + root.render( + + + + ) + }) +} + +function scroller(): HTMLDivElement { + const element = container.querySelector('.overflow-auto') + if (!element) { + throw new Error('source control scroller not found') + } + return element +} + +function scrollTo(offset: number): void { + const element = scroller() + // Why: happy-dom clamps scrollTop against its zero-height layout; pin the + // property so the virtualizer's scroll handler reads the intended offset. + Object.defineProperty(element, 'scrollTop', { + configurable: true, + writable: true, + value: offset + }) + act(() => { + element.dispatchEvent(new Event('scroll')) + }) +} + +function mountedRows(): HTMLDivElement[] { + return Array.from( + container.querySelectorAll('[data-testid="source-control-entry"]') + ) +} + +function row(path: string): HTMLDivElement | null { + return container.querySelector( + `[data-source-control-path="${path}"][data-source-control-area="unstaged"]` + ) +} + +function virtualList(): HTMLDivElement | null { + return container.querySelector('[data-testid="source-control-virtual-list"]') +} + +describe('SourceControl virtualized changed-files list', () => { + it('bounds mounted rows by viewport + overscan with 500 entries', () => { + resetState({ + gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) } + }) + renderSourceControl() + + expect(virtualList()).toBeTruthy() + const mounted = mountedRows().length + expect(mounted).toBeGreaterThan(0) + expect(mounted).toBeLessThanOrEqual(MAX_MOUNTED_ROWS) + }) + + it('windows rows while scrolling instead of mounting the tail', () => { + resetState({ + gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) } + }) + renderSourceControl() + + expect(row('src/file-000.ts')).toBeTruthy() + expect(row('src/file-250.ts')).toBeNull() + + scrollTo(250 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX) + + expect(row('src/file-250.ts')).toBeTruthy() + expect(row('src/file-000.ts')).toBeNull() + expect(mountedRows().length).toBeLessThanOrEqual(MAX_MOUNTED_ROWS) + }) + + it('keeps row selection across the virtualized boundary', () => { + resetState({ + gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) } + }) + renderSourceControl() + + const target = row('src/file-000.ts') + expect(target).toBeTruthy() + act(() => { + target?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })) + }) + expect(row('src/file-000.ts')?.className).toContain('bg-accent/60') + expect(container.textContent).toContain('1 selected') + + // The selected row scrolls out of the mounted window but stays selected. + scrollTo(240 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX) + expect(row('src/file-000.ts')).toBeNull() + expect(container.textContent).toContain('1 selected') + + scrollTo(0) + expect(row('src/file-000.ts')?.className).toContain('bg-accent/60') + }) + + it('collapses and re-expands a virtualized section', () => { + resetState({ + gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) } + }) + renderSourceControl() + expect(mountedRows().length).toBeGreaterThan(0) + + const changesHeader = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Changes') + ) + expect(changesHeader).toBeTruthy() + + act(() => { + changesHeader?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(mountedRows().length).toBe(0) + expect(virtualList()).toBeNull() + + act(() => { + changesHeader?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(mountedRows().length).toBeGreaterThan(0) + expect(mountedRows().length).toBeLessThanOrEqual(MAX_MOUNTED_ROWS) + }) + + it('keeps the scroll offset and window when a refresh delivers identical entries', () => { + resetState({ + gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) } + }) + renderSourceControl() + + scrollTo(100 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX) + expect(row('src/file-100.ts')).toBeTruthy() + const heightBefore = virtualList()?.style.height + + // A status refresh rebuilds every entry object with identical content. + mocks.state = { + ...mocks.state, + gitStatusByWorktree: { + [mocks.activeWorktree.id]: manyEntries(500).map((entry) => ({ ...entry })) + } + } + renderSourceControl() + + expect(scroller().scrollTop).toBe(100 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX) + expect(row('src/file-100.ts')).toBeTruthy() + expect(virtualList()?.style.height).toBe(heightBefore) + }) + + it('renders small changesets in full without the virtualization container', () => { + const paths = ['src/a.ts', 'src/b.ts', 'src/c.ts'] + resetState({ + gitStatusByWorktree: { + [mocks.activeWorktree.id]: paths.map((path) => gitEntry({ path })) + } + }) + renderSourceControl() + + expect(paths.length).toBeLessThan(SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS) + expect(virtualList()).toBeNull() + expect(mountedRows().length).toBe(paths.length) + for (const path of paths) { + expect(row(path)).toBeTruthy() + } + }) + + it('bounds mounted tree-view rows for large changesets', () => { + resetState({ + settings: { sourceControlViewMode: 'tree' }, + gitStatusByWorktree: { + [mocks.activeWorktree.id]: Array.from({ length: 500 }, (_, index) => + gitEntry({ + path: `src/dir-${String(Math.floor(index / 20)).padStart(2, '0')}/file-${String( + index + ).padStart(3, '0')}.ts` + }) + ) + } + }) + renderSourceControl() + + expect(virtualList()).toBeTruthy() + const mounted = container.querySelectorAll( + '[data-testid="source-control-virtual-list"] [data-index]' + ).length + expect(mounted).toBeGreaterThan(0) + expect(mounted).toBeLessThanOrEqual(MAX_MOUNTED_ROWS) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-virtual-file-list.tsx b/src/renderer/src/components/right-sidebar/source-control-virtual-file-list.tsx new file mode 100644 index 00000000000..8c71cbe7279 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-virtual-file-list.tsx @@ -0,0 +1,106 @@ +import { useLayoutEffect, useRef, useState } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' + +// Why: below this count plain rows keep the DOM identical to the +// pre-virtualization markup (natural flow, no absolute positioning), so small +// changesets keep exact scrollbar and flicker-free behavior. `STA-351` / +// `STA-1280` jank only appears with hundreds of rows. +export const SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS = 50 +// Why: rows are one py-1 text-xs line, except conflict/submodule rows which +// add a second label line — so estimate the common height and let +// measureElement correct the tall variants. Identical entries measure +// identically, so a git-status refresh cannot move the scroll position. +export const SOURCE_CONTROL_FILE_ROW_HEIGHT_PX = 24 +export const SOURCE_CONTROL_FILE_ROW_OVERSCAN = 10 + +/** + * Windows one source-control section's rows inside the panel's shared + * scroller. Sections below SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS render plainly; + * larger ones mount only viewport + overscan rows. + */ +export function SourceControlVirtualFileList({ + rows, + getRowKey, + renderRow, + scrollElement +}: { + rows: readonly TRow[] + getRowKey: (row: TRow) => string + renderRow: (row: TRow) => React.ReactNode + // Why: a state-held element, not a ref — ancestor host refs are not attached + // yet when this component's mount effects run, so a ref would leave the + // virtualizer unobserved until some unrelated re-render. + scrollElement: HTMLDivElement | null +}): React.JSX.Element { + const containerRef = useRef(null) + const [scrollMargin, setScrollMargin] = useState(0) + const virtualize = rows.length >= SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS + + // Why: the section shares the panel scroller with the commit area and + // sibling sections, so the virtualizer needs this list's offset inside that + // scroller. Everything above changes height only through React renders + // (commit drafts, banners, section collapse), so a per-render layout read + // keeps the margin current without observers. The guarded setState makes the + // measure/render loop converge in one extra pass. + // eslint-disable-next-line react-hooks/exhaustive-deps -- no dependency list on purpose: any sibling render can move this list inside the shared scroller. + useLayoutEffect(() => { + const container = containerRef.current + if (!container || !scrollElement) { + return + } + const nextMargin = Math.round( + container.getBoundingClientRect().top - + scrollElement.getBoundingClientRect().top + + scrollElement.scrollTop + ) + setScrollMargin((current) => (current === nextMargin ? current : nextMargin)) + }) + + const virtualizer = useVirtualizer({ + count: rows.length, + enabled: virtualize && scrollElement !== null, + getScrollElement: () => scrollElement, + estimateSize: () => SOURCE_CONTROL_FILE_ROW_HEIGHT_PX, + overscan: SOURCE_CONTROL_FILE_ROW_OVERSCAN, + scrollMargin, + // Why: stable row keys let the virtualizer carry item identity across + // status refreshes instead of remounting the window each poll. + getItemKey: (index) => { + const row = rows[index] + return row === undefined ? index : getRowKey(row) + } + }) + + if (!virtualize) { + return <>{rows.map((row) => renderRow(row))} + } + + return ( +
+ {virtualizer.getVirtualItems().map((item) => { + const row = rows[item.index] + if (row === undefined) { + return null + } + return ( +
+ {renderRow(row)} +
+ ) + })} +
+ ) +} From 6166f6c47a9c37f18b4d913964eb9c2246a3859e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:23:56 -0700 Subject: [PATCH 09/17] feat(linear): add server-side issue filters for status and other properties (#8021) Add attribute filters (status, priority, assignee, labels) to the Linear issues list, applied in GraphQL before pagination so hasMore matches the filtered set. Thread filters through IPC/RPC/store cache with invalidation on issue mutations, and mirror GitHub-style filter chrome on TaskPage. --- src/main/ipc/linear.test.ts | 38 +- src/main/ipc/linear.ts | 18 +- src/main/linear/issue-list-filter.test.ts | 80 ++++ src/main/linear/issue-list-filter.ts | 117 ++++++ src/main/linear/issues.test.ts | 46 ++- src/main/linear/issues.ts | 72 ++-- src/main/runtime/orca-runtime.ts | 11 +- .../linear-issue-attribute-filter-schema.ts | 35 ++ src/main/runtime/rpc/methods/linear.test.ts | 4 +- src/main/runtime/rpc/methods/linear.ts | 9 +- src/preload/api-types.ts | 2 + src/preload/index.ts | 1 + .../src/components/LinearItemDrawer.tsx | 4 + src/renderer/src/components/TaskPage.tsx | 235 +++++++++-- ...-issue-attribute-filter-dropdowns.test.tsx | 40 ++ ...inear-issue-attribute-filter-dropdowns.tsx | 298 ++++++++++++++ ...ssue-attribute-filter-primary-team.test.ts | 38 ++ ...ear-issue-attribute-filter-primary-team.ts | 30 ++ ...linear-issue-attribute-filter-sections.tsx | 377 ++++++++++++++++++ .../new-workspace/SmartWorkspaceNameField.tsx | 7 +- ...task-page-linear-issue-empty-state.test.ts | 82 ++++ .../task-page-linear-issue-empty-state.ts | 46 +++ .../task-page-linear-issue-request.test.ts | 77 ++++ .../task-page-linear-issue-request.ts | 80 ++++ src/renderer/src/i18n/locales/en.json | 28 +- src/renderer/src/i18n/locales/es.json | 28 +- src/renderer/src/i18n/locales/ja.json | 28 +- src/renderer/src/i18n/locales/ko.json | 28 +- src/renderer/src/i18n/locales/zh.json | 28 +- .../src/runtime/runtime-linear-client.ts | 31 +- .../store/slices/linear-invalidation.test.ts | 12 +- src/renderer/src/store/slices/linear.test.ts | 110 ++++- src/renderer/src/store/slices/linear.ts | 115 +++++- .../linear-issue-attribute-filter.test.ts | 137 +++++++ src/shared/linear-issue-attribute-filter.ts | 244 ++++++++++++ 35 files changed, 2401 insertions(+), 135 deletions(-) create mode 100644 src/main/linear/issue-list-filter.test.ts create mode 100644 src/main/linear/issue-list-filter.ts create mode 100644 src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts create mode 100644 src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx create mode 100644 src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx create mode 100644 src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts create mode 100644 src/renderer/src/components/linear-issue-attribute-filter-primary-team.ts create mode 100644 src/renderer/src/components/linear-issue-attribute-filter-sections.tsx create mode 100644 src/renderer/src/components/task-page-linear-issue-empty-state.test.ts create mode 100644 src/renderer/src/components/task-page-linear-issue-empty-state.ts create mode 100644 src/renderer/src/components/task-page-linear-issue-request.test.ts create mode 100644 src/renderer/src/components/task-page-linear-issue-request.ts create mode 100644 src/shared/linear-issue-attribute-filter.test.ts create mode 100644 src/shared/linear-issue-attribute-filter.ts diff --git a/src/main/ipc/linear.test.ts b/src/main/ipc/linear.test.ts index 1f52cb08265..d022497486b 100644 --- a/src/main/ipc/linear.test.ts +++ b/src/main/ipc/linear.test.ts @@ -97,7 +97,43 @@ describe('registerLinearHandlers', () => { workspaceId: 'workspace-1' }) - expect(listIssuesMock).toHaveBeenCalledWith('all', 216, 'workspace-1') + expect(listIssuesMock).toHaveBeenCalledWith('all', 216, 'workspace-1', { + attributeFilter: undefined + }) + }) + + it('parses attribute filters and rejects malformed payloads before listIssues', async () => { + listIssuesMock.mockResolvedValue({ items: [], hasMore: false }) + registerLinearHandlers() + + await handlers['linear:listIssues'](null, { + filter: 'all', + limit: 20, + workspaceId: 'workspace-1', + attributeFilter: { + stateIds: ['state-1'], + priorities: [1], + assignee: { kind: 'unassigned' }, + labelIds: [] + } + }) + expect(listIssuesMock).toHaveBeenCalledWith('all', 20, 'workspace-1', { + attributeFilter: { + stateIds: ['state-1'], + priorities: [1], + assignee: { kind: 'unassigned' }, + labelIds: [] + } + }) + + listIssuesMock.mockClear() + await expect( + handlers['linear:listIssues'](null, { + filter: 'all', + attributeFilter: { stateIds: [] } + }) + ).rejects.toThrow(/required/) + expect(listIssuesMock).not.toHaveBeenCalled() }) it('forwards expanded Linear project issue limits through local IPC', async () => { diff --git a/src/main/ipc/linear.ts b/src/main/ipc/linear.ts index 1cf04dbb558..2e70cd0564d 100644 --- a/src/main/ipc/linear.ts +++ b/src/main/ipc/linear.ts @@ -25,6 +25,7 @@ import { import { listTeams, getTeamStates, getTeamLabels, getTeamMembers } from '../linear/teams' import type { LinearListFilter } from '../linear/issues' import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' +import { optionalParsedLinearIssueAttributeFilter } from '../../shared/linear-issue-attribute-filter' import type { LinearCustomViewModel, LinearIssueUpdate, @@ -131,13 +132,26 @@ export function registerLinearHandlers(): void { 'linear:listIssues', async ( _event, - args?: { filter?: LinearListFilter; limit?: number; workspaceId?: LinearWorkspaceSelection } + args?: { + filter?: LinearListFilter + limit?: number + workspaceId?: LinearWorkspaceSelection + attributeFilter?: unknown + } ) => { const filter = VALID_FILTERS.has(args?.filter as LinearListFilter) ? (args!.filter as LinearListFilter) : undefined const limit = clampLinearIssueListLimit(args?.limit) - return listIssues(filter, limit, normalizeWorkspaceSelection(args?.workspaceId)) + // Why: reject malformed filters at the trust boundary instead of + // normalizing them to empty (which would silently broaden results). + const attributeFilter = + args && 'attributeFilter' in args && args.attributeFilter !== undefined + ? optionalParsedLinearIssueAttributeFilter(args.attributeFilter) + : undefined + return listIssues(filter, limit, normalizeWorkspaceSelection(args?.workspaceId), { + attributeFilter + }) } ) diff --git a/src/main/linear/issue-list-filter.test.ts b/src/main/linear/issue-list-filter.test.ts new file mode 100644 index 00000000000..24fb3af3fd8 --- /dev/null +++ b/src/main/linear/issue-list-filter.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { buildLinearListIssueFilter } from './issue-list-filter' + +describe('buildLinearListIssueFilter', () => { + it('returns undefined for all preset without attributes or team', () => { + expect(buildLinearListIssueFilter({ filter: 'all' })).toBeUndefined() + }) + + it('merges status ids, priority, assignee, and labels for the all preset', () => { + expect( + buildLinearListIssueFilter({ + filter: 'all', + attributeFilter: { + stateIds: ['state-1', 'state-2'], + priorities: [0, 1], + assignee: { kind: 'user', id: 'user-1' }, + labelIds: ['label-1', 'label-2'] + } + }) + ).toEqual({ + state: { id: { in: ['state-1', 'state-2'] } }, + priority: { in: [0, 1] }, + assignee: { id: { eq: 'user-1' } }, + labels: { some: { id: { in: ['label-1', 'label-2'] } } } + }) + }) + + it('keeps preset state.type and attribute state.id together', () => { + expect( + buildLinearListIssueFilter({ + filter: 'assigned', + attributeFilter: { + stateIds: ['state-open'], + priorities: [], + assignee: null, + labelIds: [] + } + }) + ).toEqual({ + state: { + type: { nin: ['completed', 'canceled'] }, + id: { in: ['state-open'] } + } + }) + }) + + it('shapes unassigned and optional team filters', () => { + expect( + buildLinearListIssueFilter({ + filter: 'all', + teamId: 'team-1', + attributeFilter: { + stateIds: [], + priorities: [], + assignee: { kind: 'unassigned' }, + labelIds: [] + } + }) + ).toEqual({ + team: { id: { eq: 'team-1' } }, + assignee: { null: true } + }) + }) + + it('omits empty attribute filters so variables match the unfiltered path', () => { + expect( + buildLinearListIssueFilter({ + filter: 'completed', + attributeFilter: { + stateIds: [], + priorities: [], + assignee: null, + labelIds: [] + } + }) + ).toEqual({ + state: { type: { in: ['completed', 'canceled'] } } + }) + }) +}) diff --git a/src/main/linear/issue-list-filter.ts b/src/main/linear/issue-list-filter.ts new file mode 100644 index 00000000000..b85b7e3ba09 --- /dev/null +++ b/src/main/linear/issue-list-filter.ts @@ -0,0 +1,117 @@ +import { + isEmptyLinearIssueAttributeFilter, + type LinearIssueAttributeFilter +} from '../../shared/linear-issue-attribute-filter' +import type { LinearListFilter } from './issues' + +// Why: attribute facets must merge into Linear's IssueFilter before the first-N +// cursor walk so hasMore reflects the filtered set, not the unfiltered window. +// @linear/sdk does not export IssueFilter as a public type, so we keep a +// structural subset that matches the operators this list path actually sends. + +export type LinearGraphQLIssueFilter = { + state?: { + type?: { nin?: string[]; in?: string[] } + id?: { in?: string[] } + } + team?: { id?: { eq?: string } } + priority?: { in?: number[] } + assignee?: { null?: boolean; id?: { eq?: string } } + labels?: { some?: { id?: { in?: string[] } } } +} + +const ACTIVE_STATE_FILTER: LinearGraphQLIssueFilter = { + state: { type: { nin: ['completed', 'canceled'] } } +} +const COMPLETED_STATE_FILTER: LinearGraphQLIssueFilter = { + state: { type: { in: ['completed', 'canceled'] } } +} + +function listFilterForState(filter: LinearListFilter): LinearGraphQLIssueFilter | undefined { + if (filter === 'assigned' || filter === 'created' || filter === 'open') { + return ACTIVE_STATE_FILTER + } + if (filter === 'completed') { + return COMPLETED_STATE_FILTER + } + return undefined +} + +function attributeFacetFilter( + attributeFilter: LinearIssueAttributeFilter | null | undefined +): LinearGraphQLIssueFilter | undefined { + if (!attributeFilter || isEmptyLinearIssueAttributeFilter(attributeFilter)) { + return undefined + } + + const next: LinearGraphQLIssueFilter = {} + + if (attributeFilter.stateIds.length > 0) { + next.state = { id: { in: attributeFilter.stateIds } } + } + + if (attributeFilter.priorities.length > 0) { + next.priority = { in: attributeFilter.priorities } + } + + if (attributeFilter.assignee?.kind === 'unassigned') { + next.assignee = { null: true } + } else if (attributeFilter.assignee?.kind === 'user') { + next.assignee = { id: { eq: attributeFilter.assignee.id } } + } + + if (attributeFilter.labelIds.length > 0) { + // Why: collection `some` is any-of; a direct labels.id comparator would + // not express multi-label membership the same way. + next.labels = { some: { id: { in: attributeFilter.labelIds } } } + } + + return Object.keys(next).length > 0 ? next : undefined +} + +function mergeIssueFilters( + ...parts: (LinearGraphQLIssueFilter | undefined)[] +): LinearGraphQLIssueFilter | undefined { + const defined = parts.filter((part): part is LinearGraphQLIssueFilter => part !== undefined) + if (defined.length === 0) { + return undefined + } + if (defined.length === 1) { + return defined[0] + } + + const merged: LinearGraphQLIssueFilter = {} + for (const part of defined) { + if (part.state) { + // Why: preset state.type and attribute state.id must coexist; a top-level + // spread would overwrite one nested constraint. + merged.state = { ...merged.state, ...part.state } + } + if (part.team) { + merged.team = part.team + } + if (part.priority) { + merged.priority = part.priority + } + if (part.assignee) { + merged.assignee = part.assignee + } + if (part.labels) { + merged.labels = part.labels + } + } + return merged +} + +export function buildLinearListIssueFilter(options: { + filter: LinearListFilter + teamId?: string + attributeFilter?: LinearIssueAttributeFilter | null +}): LinearGraphQLIssueFilter | undefined { + const stateFilter = listFilterForState(options.filter) + const teamFilter: LinearGraphQLIssueFilter | undefined = options.teamId + ? { team: { id: { eq: options.teamId } } } + : undefined + const attributes = attributeFacetFilter(options.attributeFilter) + return mergeIssueFilters(stateFilter, teamFilter, attributes) +} diff --git a/src/main/linear/issues.test.ts b/src/main/linear/issues.test.ts index e341018f0ab..ef9ca38678e 100644 --- a/src/main/linear/issues.test.ts +++ b/src/main/linear/issues.test.ts @@ -188,7 +188,9 @@ describe('Linear issue queries', () => { }) const { listIssues } = await import('./issues') - await expect(listIssues('open', 10, 'workspace-1', 'team-1')).resolves.toMatchObject({ + await expect( + listIssues('open', 10, 'workspace-1', { teamId: 'team-1' }) + ).resolves.toMatchObject({ items: [{ id: 'LIN-1' }], hasMore: false }) @@ -202,6 +204,48 @@ describe('Linear issue queries', () => { }) }) + it('passes attribute facets into Linear GraphQL variables before pagination', async () => { + rawRequest.mockResolvedValueOnce({ + data: { issues: { nodes: [rawIssue('LIN-1')], pageInfo: { hasNextPage: false } } } + }) + const { listIssues } = await import('./issues') + + await expect( + listIssues('all', 10, 'workspace-1', { + attributeFilter: { + stateIds: ['state-1'], + priorities: [0, 1], + assignee: { kind: 'unassigned' }, + labelIds: ['label-1'] + } + }) + ).resolves.toMatchObject({ items: [{ id: 'LIN-1' }] }) + + expect(rawRequest.mock.calls[0][1]).toMatchObject({ + filter: { + state: { id: { in: ['state-1'] } }, + priority: { in: [0, 1] }, + assignee: { null: true }, + labels: { some: { id: { in: ['label-1'] } } } + } + }) + }) + + it('rejects non-empty attribute filters when workspace scope is all', async () => { + const { listIssues } = await import('./issues') + await expect( + listIssues('all', 10, 'all', { + attributeFilter: { + stateIds: ['state-1'], + priorities: [], + assignee: null, + labelIds: [] + } + }) + ).rejects.toThrow(/concrete workspace/i) + expect(getClients).not.toHaveBeenCalled() + }) + it('keeps single-workspace search results in Linear relevance order', async () => { rawRequest.mockResolvedValueOnce({ data: { diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index b54c385d040..f71ab7e35e1 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -14,6 +14,10 @@ import { LINEAR_ISSUE_API_PAGE_SIZE_MAX, clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' +import { + isEmptyLinearIssueAttributeFilter, + type LinearIssueAttributeFilter +} from '../../shared/linear-issue-attribute-filter' import { acquire, release, @@ -22,8 +26,14 @@ import { clearToken, type LinearClientForWorkspace } from './client' +import { buildLinearListIssueFilter } from './issue-list-filter' import { mapLinearIssue } from './mappers' +export type LinearIssueListOptions = { + teamId?: string + attributeFilter?: LinearIssueAttributeFilter | null +} + type LinearIssueNode = { id: string identifier: string @@ -457,11 +467,17 @@ function getOldestIssueTime(issues: LinearIssue[]): number { function getListIssueConnectionLoader( entry: LinearClientForWorkspace, filter: LinearListFilter, - teamId?: string + options?: LinearIssueListOptions ): LinearIssueConnectionLoader { const orderBy = 'updatedAt' const variables = { orderBy } - const filterInput = listIssueFilter(filter, teamId) + // Why: apply attribute + team filters in GraphQL variables before the first-N + // cursor walk so pagination hasMore matches the filtered set. + const filterInput = buildLinearListIssueFilter({ + filter, + teamId: options?.teamId, + attributeFilter: options?.attributeFilter + }) if (filter === 'assigned') { return async (page) => { @@ -833,31 +849,6 @@ export async function searchIssues( export type LinearListFilter = 'assigned' | 'created' | 'all' | 'completed' | 'open' -const ACTIVE_STATE_FILTER = { state: { type: { nin: ['completed', 'canceled'] } } } -const COMPLETED_STATE_FILTER = { state: { type: { in: ['completed', 'canceled'] } } } - -function listFilterForState(filter: LinearListFilter): Record | undefined { - if (filter === 'assigned' || filter === 'created' || filter === 'open') { - return ACTIVE_STATE_FILTER - } - if (filter === 'completed') { - return COMPLETED_STATE_FILTER - } - return undefined -} - -function listIssueFilter( - filter: LinearListFilter, - teamId?: string -): Record | undefined { - const stateFilter = listFilterForState(filter) - const teamFilter = teamId ? { team: { id: { eq: teamId } } } : undefined - if (stateFilter && teamFilter) { - return { ...stateFilter, ...teamFilter } - } - return stateFilter ?? teamFilter -} - type LinearIssuePageResult = { items: LinearIssue[] hasMore: boolean @@ -904,14 +895,14 @@ async function readListIssuesForWorkspace( filter: LinearListFilter, limit: number, workspaceId: LinearWorkspaceSelection | null | undefined, - teamId?: string + options?: LinearIssueListOptions ): Promise> { await acquire() try { return await readIssueConnectionPages( entry, limit, - getListIssueConnectionLoader(entry, filter, teamId) + getListIssueConnectionLoader(entry, filter, options) ) } catch (error) { if (isAuthError(error)) { @@ -1016,11 +1007,11 @@ async function readListIssuesAcrossWorkspaces( filter: LinearListFilter, limit: number, workspaceId: LinearWorkspaceSelection | null | undefined, - teamId?: string + options?: LinearIssueListOptions ): Promise> { const states: LinearIssueWorkspacePageState[] = entries.map((entry) => ({ entry, - loadConnection: getListIssueConnectionLoader(entry, filter, teamId), + loadConnection: getListIssueConnectionLoader(entry, filter, options), items: [], hasMore: false, canPage: false @@ -1063,19 +1054,32 @@ export async function listIssues( filter: LinearListFilter = 'assigned', limit = 20, workspaceId?: LinearWorkspaceSelection | null, - teamId?: string + options?: LinearIssueListOptions ): Promise> { const effectiveLimit = clampLinearIssueListLimit(limit) + const attributeFilter = options?.attributeFilter + // Why: workspace-specific state/member/label ids cannot fan out safely across + // "all" workspaces; reject before creating clients so non-UI callers cannot + // get a misleading partial subset. + if ( + attributeFilter && + !isEmptyLinearIssueAttributeFilter(attributeFilter) && + workspaceId === 'all' + ) { + throw new Error( + 'Linear attribute filters require a concrete workspace; "all" workspaces is not supported.' + ) + } const entries = getClients(workspaceId) if (entries.length === 0) { return { items: [] } } if (entries.length === 1) { - return readListIssuesForWorkspace(entries[0], filter, effectiveLimit, workspaceId, teamId) + return readListIssuesForWorkspace(entries[0], filter, effectiveLimit, workspaceId, options) } - return readListIssuesAcrossWorkspaces(entries, filter, effectiveLimit, workspaceId, teamId) + return readListIssuesAcrossWorkspaces(entries, filter, effectiveLimit, workspaceId, options) } export async function createIssue( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 43ea05133bb..305a15a2127 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -480,7 +480,8 @@ import { updateIssueForAgent as updateLinearIssueForAgent, updateIssue as updateLinearIssue, LinearWriteFailure, - type LinearListFilter + type LinearListFilter, + type LinearIssueListOptions } from '../linear/issues' import { LinearAgentAccessError, @@ -20799,7 +20800,9 @@ export class OrcaRuntimeService { : null const workspaceId = team?.workspaceId ?? params.workspaceId try { - const result = await listLinearIssues(filter, limit, workspaceId, team?.id) + const result = await listLinearIssues(filter, limit, workspaceId, { + teamId: team?.id + }) return { issues: result.items.map((issue) => ({ id: issue.id, @@ -20927,9 +20930,9 @@ export class OrcaRuntimeService { filter?: LinearListFilter, limit = 20, workspaceId?: LinearWorkspaceSelection, - teamId?: string + options?: LinearIssueListOptions ): ReturnType { - return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, teamId) + return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, options) } linearCreateIssue( diff --git a/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts b/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts new file mode 100644 index 00000000000..8d9dde7ddc8 --- /dev/null +++ b/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import { + LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH, + LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS, + LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES, + LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS +} from '../../../../shared/linear-issue-attribute-filter' + +// Why: keep ListIssues param validation co-located with shared limits without +// pushing linear.ts past the max-lines ratchet. +const LinearAttributeFilterId = z + .string() + .trim() + .min(1) + .max(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH) + +export const LinearIssueAttributeFilterSchema = z + .object({ + stateIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS), + priorities: z + .array(z.number().int().min(0).max(4)) + .max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES), + assignee: z.union([ + z.object({ kind: z.literal('unassigned') }).strict(), + z + .object({ + kind: z.literal('user'), + id: LinearAttributeFilterId + }) + .strict(), + z.null() + ]), + labelIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS) + }) + .strict() diff --git a/src/main/runtime/rpc/methods/linear.test.ts b/src/main/runtime/rpc/methods/linear.test.ts index f2c68db7801..f5726444519 100644 --- a/src/main/runtime/rpc/methods/linear.test.ts +++ b/src/main/runtime/rpc/methods/linear.test.ts @@ -102,7 +102,9 @@ describe('linear RPC methods', () => { ) expect(runtime.linearSearchIssues).toHaveBeenCalledWith('bug', 30, 'all') - expect(runtime.linearListIssues).toHaveBeenCalledWith('assigned', 20, 'workspace-1') + expect(runtime.linearListIssues).toHaveBeenCalledWith('assigned', 20, 'workspace-1', { + attributeFilter: undefined + }) expect(runtime.linearGetIssue).toHaveBeenCalledWith('issue-3', 'workspace-1') expect(runtime.linearCreateIssue).toHaveBeenCalledWith( 'team-1', diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts index d3581f78310..76b6f0a4128 100644 --- a/src/main/runtime/rpc/methods/linear.ts +++ b/src/main/runtime/rpc/methods/linear.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' import { LINEAR_PROJECT_CREATE_METHOD } from './linear-project-create' +import { LinearIssueAttributeFilterSchema } from './linear-issue-attribute-filter-schema' const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const @@ -37,8 +38,10 @@ const ListIssues = z .object({ filter: z.enum(VALID_FILTERS).optional(), limit: OptionalFiniteNumber, - workspaceId: OptionalString + workspaceId: OptionalString, + attributeFilter: LinearIssueAttributeFilterSchema.optional() }) + .strict() .optional() const CreateIssue = z.object({ @@ -164,7 +167,9 @@ export const LINEAR_METHODS: RpcMethod[] = [ name: 'linear.listIssues', params: ListIssues, handler: async (params, { runtime }) => - runtime.linearListIssues(params?.filter, params?.limit, params?.workspaceId) + runtime.linearListIssues(params?.filter, params?.limit, params?.workspaceId, { + attributeFilter: params?.attributeFilter + }) }), defineMethod({ name: 'linear.createIssue', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 4ec3557beeb..3496e31a029 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -39,6 +39,7 @@ import type { } from '../shared/orca-profiles' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' import type { TaskSourceContext } from '../shared/task-source-context' +import type { LinearIssueAttributeFilter } from '../shared/linear-issue-attribute-filter' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume' @@ -1776,6 +1777,7 @@ export type PreloadApi = { filter?: 'assigned' | 'created' | 'all' | 'completed' limit?: number workspaceId?: LinearWorkspaceSelection + attributeFilter?: LinearIssueAttributeFilter }) => Promise> createIssue: (args: { teamId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index d7385f18fc2..049acbb20ec 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1462,6 +1462,7 @@ const api = { filter?: 'assigned' | 'created' | 'all' | 'completed' limit?: number workspaceId?: string + attributeFilter?: unknown }): Promise => ipcRenderer.invoke('linear:listIssues', args), createIssue: (args: { diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 6309130d2fc..25ee1ffef16 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -203,6 +203,7 @@ export function LinearIssueEditSection({ patchLinearIssue(issue.id, { state: prevState }, { sourceContext }) }, onSuccess: () => { + useAppStore.getState().invalidateLinearIssueLists({ sourceContext }) useAppStore.getState().recordFeatureInteraction('linear-tasks') }, onError: (err) => toast.error(err) @@ -237,6 +238,7 @@ export function LinearIssueEditSection({ patchLinearIssue(issue.id, { priority: prevPriority }, { sourceContext }) }, onSuccess: () => { + useAppStore.getState().invalidateLinearIssueLists({ sourceContext }) useAppStore.getState().recordFeatureInteraction('linear-tasks') }, onError: (err) => toast.error(err) @@ -328,6 +330,7 @@ export function LinearIssueEditSection({ patchLinearIssue(issue.id, { assignee: prevAssignee }, { sourceContext }) }, onSuccess: () => { + useAppStore.getState().invalidateLinearIssueLists({ sourceContext }) useAppStore.getState().recordFeatureInteraction('linear-tasks') }, onError: (err) => toast.error(err) @@ -383,6 +386,7 @@ export function LinearIssueEditSection({ ) }, onSuccess: () => { + useAppStore.getState().invalidateLinearIssueLists({ sourceContext }) useAppStore.getState().recordFeatureInteraction('linear-tasks') }, onError: (err) => toast.error(err) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index cbb3633d0cf..810063e3929 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -199,6 +199,24 @@ import { type TaskPageRepoSourceState } from '@/components/task-page-cache-selectors' import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility' +import LinearIssueAttributeFilterDropdowns from '@/components/linear-issue-attribute-filter-dropdowns' +import { resolveLinearIssueAttributeFilterPrimaryTeam } from '@/components/linear-issue-attribute-filter-primary-team' +import { + buildLinearIssueListReadArgs, + buildLinearIssueListRequestSignature, + isLinearIssueSearchActive, + shouldForceLinearIssueListRead, + teamDerivedFacetsForPrimaryTeamChange +} from '@/components/task-page-linear-issue-request' +import { + resolveLinearIssueEmptyKind, + shouldOfferLinearIssueFetchMore +} from '@/components/task-page-linear-issue-empty-state' +import { + emptyLinearIssueAttributeFilter, + linearIssueAttributeFilterSignature, + type LinearIssueAttributeFilter +} from '../../../shared/linear-issue-attribute-filter' import { isNewIssueDraftContentful, resolveNewIssueOpenSeed, @@ -652,27 +670,28 @@ function LinearStateCell({ } setPending(true) - patchLinearIssue(issue.id, { state: nextState }) + patchLinearIssue(issue.id, { state: nextState }, { sourceContext }) void linearUpdateIssue(providerSettings, issue.id, { stateId }, issue.workspaceId) .then((result) => { if (reqId !== reqRef.current) { return } if (result.ok === false) { - patchLinearIssue(issue.id, { state: previousState }) + patchLinearIssue(issue.id, { state: previousState }, { sourceContext }) toast.error( result.error ?? translate('auto.components.TaskPage.6775c05483', 'Failed to update Linear state') ) return } + useAppStore.getState().invalidateLinearIssueLists({ sourceContext }) useAppStore.getState().recordFeatureInteraction('linear-tasks') }) .catch(() => { if (reqId !== reqRef.current) { return } - patchLinearIssue(issue.id, { state: previousState }) + patchLinearIssue(issue.id, { state: previousState }, { sourceContext }) toast.error( translate('auto.components.TaskPage.6775c05483', 'Failed to update Linear state') ) @@ -691,6 +710,7 @@ function LinearStateCell({ patchLinearIssue, pending, providerSettings, + sourceContext, states.data ] ) @@ -3054,6 +3074,8 @@ export default function TaskPage(): React.JSX.Element { const selectLinearWorkspace = useAppStore((s) => s.selectLinearWorkspace) const searchLinearIssues = useAppStore((s) => s.searchLinearIssues) const listLinearIssues = useAppStore((s) => s.listLinearIssues) + const linearListInvalidationToken = useAppStore((s) => s.linearListInvalidationToken) + const invalidateLinearIssueLists = useAppStore((s) => s.invalidateLinearIssueLists) const getCachedLinearIssues = useAppStore((s) => s.getCachedLinearIssues) const getCachedLinearTeams = useAppStore((s) => s.getCachedLinearTeams) const listLinearTeams = useAppStore((s) => s.listLinearTeams) @@ -3452,6 +3474,13 @@ export default function TaskPage(): React.JSX.Element { selectedLinearWorkspaceId ] ) + // Why: only react to invalidation tokens for this TaskPage source scope. + const linearListInvalidationVersionForSource = useMemo(() => { + const scope = linearTaskSourceContext + ? getTaskSourceCacheScope(linearTaskSourceContext) + : 'local' + return linearListInvalidationToken.scope === scope ? linearListInvalidationToken.version : 0 + }, [linearListInvalidationToken, linearTaskSourceContext]) const jiraTaskSourceContext = useMemo( () => normalizeTaskSourceContext({ @@ -4355,6 +4384,14 @@ export default function TaskPage(): React.JSX.Element { const [linearError, setLinearError] = useState(null) const [linearSearchInput, setLinearSearchInput] = useState('') const [appliedLinearSearch, setAppliedLinearSearch] = useState('') + const [linearAttributeFilter, setLinearAttributeFilter] = useState( + () => emptyLinearIssueAttributeFilter() + ) + const linearAttributeFilterSignatureRef = useRef( + linearIssueAttributeFilterSignature(emptyLinearIssueAttributeFilter()) + ) + const linearPrimaryTeamIdRef = useRef(null) + const previousLinearWorkspaceIdForFiltersRef = useRef(undefined) const [linearViewMode, setLinearViewMode] = useState('list') const [linearGroupBy, setLinearGroupBy] = useState('none') const [linearOrderBy, setLinearOrderBy] = useState('priority') @@ -5100,6 +5137,57 @@ export default function TaskPage(): React.JSX.Element { ) }, [linearTeamOptions, defaultLinearTeamSelection]) + const linearAttributePrimaryTeam = useMemo( + () => + resolveLinearIssueAttributeFilterPrimaryTeam({ + selectedTeamIds: [...linearTeamSelection], + availableTeams: linearTeamOptions + }), + [linearTeamOptions, linearTeamSelection] + ) + + const applyLinearAttributeFilter = useCallback((next: LinearIssueAttributeFilter) => { + // Why: batch filter + limit/page reset in one transition so the fetch + // effect never issues an old expanded-limit request for the new filter. + setLinearAttributeFilter(next) + setLinearIssueLimit(LINEAR_ITEM_LIMIT) + setLinearIssuePage(0) + setLinearIssueLoadingTargetPage(null) + }, []) + + useEffect(() => { + const workspaceId = selectedLinearWorkspaceId ?? null + const previous = previousLinearWorkspaceIdForFiltersRef.current + previousLinearWorkspaceIdForFiltersRef.current = workspaceId + if (previous === undefined || previous === workspaceId) { + return + } + applyLinearAttributeFilter(emptyLinearIssueAttributeFilter()) + }, [applyLinearAttributeFilter, selectedLinearWorkspaceId]) + + useEffect(() => { + const nextId = linearAttributePrimaryTeam?.id ?? null + const previousId = linearPrimaryTeamIdRef.current + linearPrimaryTeamIdRef.current = nextId + if (previousId === null || previousId === nextId) { + return + } + // Why: status/assignee/labels are team-scoped; clearing them is a filter change + // and must reset limit/page via applyLinearAttributeFilter (R6), not a bare set. + const next = teamDerivedFacetsForPrimaryTeamChange(linearAttributeFilter) + if ( + linearIssueAttributeFilterSignature(linearAttributeFilter) === + linearIssueAttributeFilterSignature(next) + ) { + return + } + applyLinearAttributeFilter(next) + }, [applyLinearAttributeFilter, linearAttributeFilter, linearAttributePrimaryTeam?.id]) + + const linearSearchActive = isLinearIssueSearchActive(linearSearchInput, appliedLinearSearch) + const showLinearAttributeFilters = + linearMode === 'issues' && !activeLinearIssueContextLabel && !linearSearchActive + const filteredLinearIssues = useMemo(() => { if (activeLinearIssueContextLabel) { return displayedLinearIssues @@ -5418,7 +5506,7 @@ export default function TaskPage(): React.JSX.Element { color: workflowState.color } - patchLinearIssue(issue.id, { state: nextState }) + patchLinearIssue(issue.id, { state: nextState }, { sourceContext: linearTaskSourceContext }) patchScopedLinearIssue(issue.id, { state: nextState }) applyFallbackState(nextState) @@ -5429,7 +5517,11 @@ export default function TaskPage(): React.JSX.Element { issue.workspaceId ) if (result.ok === false) { - patchLinearIssue(issue.id, { state: previousState }) + patchLinearIssue( + issue.id, + { state: previousState }, + { sourceContext: linearTaskSourceContext } + ) patchScopedLinearIssue(issue.id, { state: previousState }) applyFallbackState(previousState) toast.error( @@ -5438,9 +5530,14 @@ export default function TaskPage(): React.JSX.Element { ) return } + invalidateLinearIssueLists({ sourceContext: linearTaskSourceContext }) useAppStore.getState().recordFeatureInteraction('linear-tasks') } catch { - patchLinearIssue(issue.id, { state: previousState }) + patchLinearIssue( + issue.id, + { state: previousState }, + { sourceContext: linearTaskSourceContext } + ) patchScopedLinearIssue(issue.id, { state: previousState }) applyFallbackState(previousState) toast.error( @@ -5456,6 +5553,7 @@ export default function TaskPage(): React.JSX.Element { }, [ filteredLinearIssues, + invalidateLinearIssueLists, linearBoardDraggingIssueId, linearBoardUpdatingIssueIds, linearStatusBoardEnabled, @@ -7177,9 +7275,8 @@ export default function TaskPage(): React.JSX.Element { taskSource ]) - // Why: fetch Linear issues when the tab is active and the account is - // connected. An empty search falls back to `listLinearIssues` (assigned - // issues) so the default view shows the user's own work. + // Why: fetch Linear issues when the tab is active and connected. Empty search + // uses the plain `all` list with optional server-side attribute filters. useEffect(() => { if (!taskResumeApplied) { return @@ -7199,10 +7296,17 @@ export default function TaskPage(): React.JSX.Element { const trimmed = appliedLinearSearch.trim() const effectiveLinearIssueLimit = clampLinearIssueListLimit(linearIssueLimit) - const readArgs = - trimmed.length > 0 - ? ({ kind: 'search', query: trimmed, limit: LINEAR_ITEM_LIMIT } as const) - : ({ kind: 'list', filter: 'all', limit: effectiveLinearIssueLimit } as const) + const searchActive = trimmed.length > 0 + const listReadArgs = buildLinearIssueListReadArgs({ + filter: 'all', + limit: effectiveLinearIssueLimit, + attributeFilter: linearAttributeFilter, + searchActive, + allowAttributeFilter: selectedLinearWorkspaceId !== 'all' + }) + const readArgs = searchActive + ? ({ kind: 'search', query: trimmed, limit: LINEAR_ITEM_LIMIT } as const) + : listReadArgs const cachedResult = getCachedLinearIssues(readArgs, { sourceContext: linearTaskSourceContext }) if (readArgs.kind === 'search') { setLinearIssuesHasMore(false) @@ -7217,15 +7321,29 @@ export default function TaskPage(): React.JSX.Element { ) } - const requestSignature = - trimmed.length > 0 - ? `${selectedLinearWorkspaceId ?? 'default'}::search::${trimmed}::${LINEAR_ITEM_LIMIT}` - : `${selectedLinearWorkspaceId ?? 'default'}::list::all::${effectiveLinearIssueLimit}` + const nextFilterSignature = linearIssueAttributeFilterSignature(linearAttributeFilter) + const previousFilterSignature = linearAttributeFilterSignatureRef.current + linearAttributeFilterSignatureRef.current = nextFilterSignature + const filterForce = shouldForceLinearIssueListRead({ + previousFilterSignature, + nextFilterSignature, + refreshForced: false + }) + + const requestSignature = buildLinearIssueListRequestSignature({ + sourceContext: linearTaskSourceContext, + workspaceId: selectedLinearWorkspaceId, + filter: 'all', + limit: effectiveLinearIssueLimit, + attributeFilter: linearAttributeFilter, + searchQuery: searchActive ? trimmed : undefined + }) const previousRequest = lastLinearRequestRef.current const forceRefresh = - linearRefreshNonce > 0 && - previousRequest?.nonce !== linearRefreshNonce && - previousRequest?.signature === requestSignature + filterForce || + (linearRefreshNonce > 0 && + previousRequest?.nonce !== linearRefreshNonce && + previousRequest?.signature === requestSignature) lastLinearRequestRef.current = { nonce: linearRefreshNonce, signature: requestSignature } const shouldProbeOnLanding = !forceRefresh && @@ -7248,7 +7366,7 @@ export default function TaskPage(): React.JSX.Element { force: forceRefresh || shouldProbeOnLanding, sourceContext: linearTaskSourceContext }) - : listLinearIssues(readArgs.filter, effectiveLinearIssueLimit, { + : listLinearIssues(listReadArgs, { force: forceRefresh || shouldProbeOnLanding, sourceContext: linearTaskSourceContext }) @@ -7311,6 +7429,8 @@ export default function TaskPage(): React.JSX.Element { appliedLinearSearch, linearIssueLimit, linearRefreshNonce, + linearAttributeFilter, + linearListInvalidationVersionForSource, taskResumeApplied, getCachedLinearIssues, linearTaskSourceContext @@ -8593,7 +8713,18 @@ export default function TaskPage(): React.JSX.Element { {linearMode === 'issues' ? ( -
+
+ {showLinearAttributeFilters ? ( + + ) : null}

- {activeLinearIssueContextLabel - ? translate( + {(() => { + const emptyKind = resolveLinearIssueEmptyKind({ + hasContextLabel: Boolean(activeLinearIssueContextLabel), + searchActive: linearSearchActive, + attributeFilter: linearAttributeFilter, + serverIssueCount: activeLinearIssues.length, + filteredIssueCount: filteredLinearIssues.length + }) + if (emptyKind === 'context') { + return translate( 'auto.components.TaskPage.25ff84769a', 'No issues match this Linear context.' ) - : linearSearchInput - ? translate( - 'auto.components.TaskPage.2bdefbcac3', - 'Try a different search query.' - ) - : translate( - 'auto.components.TaskPage.d079be2dc8', - 'No assigned issues. Try searching for something.' - )} + } + if (emptyKind === 'search') { + return translate( + 'auto.components.TaskPage.2bdefbcac3', + 'Try a different search query.' + ) + } + if (emptyKind === 'server-attribute-filter') { + return translate( + 'auto.components.TaskPage.linearEmptyAttributeFilter', + 'No issues match the selected filters. Clear a filter or try different criteria.' + ) + } + return translate( + 'auto.components.TaskPage.linearEmptyUnfilteredScope', + 'No issues in this workspace scope. Try searching or adjusting teams.' + ) + })()}

) : null} @@ -10571,6 +10719,27 @@ export default function TaskPage(): React.JSX.Element { 'Try selecting more teams or refreshing; team filters apply to the current fetched issue set.' )}

+ {shouldOfferLinearIssueFetchMore({ + emptyKind: 'client-team', + serverHasMore: linearIssuesHasMore + }) ? ( + + ) : null}
) : null} diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx new file mode 100644 index 00000000000..753e24872dc --- /dev/null +++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { + clearLinearIssueAttributeFacet, + countLinearIssueAttributeFilters, + linearIssueAttributeFilterPillLabels +} from './linear-issue-attribute-filter-sections' +import type { LinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter' + +const sample: LinearIssueAttributeFilter = { + stateIds: ['s1', 's2'], + priorities: [0, 1], + assignee: { kind: 'unassigned' }, + labelIds: ['l1'] +} + +describe('linear-issue-attribute-filter helpers', () => { + it('counts active facets and clears individual facets', () => { + expect(countLinearIssueAttributeFilters(sample)).toBe(4) + expect(clearLinearIssueAttributeFacet(sample, 'status').stateIds).toEqual([]) + expect(clearLinearIssueAttributeFacet(sample, 'priority').priorities).toEqual([]) + expect(clearLinearIssueAttributeFacet(sample, 'assignee').assignee).toBeNull() + expect(clearLinearIssueAttributeFacet(sample, 'labels').labelIds).toEqual([]) + }) + + it('builds pill labels from metadata maps', () => { + const pills = linearIssueAttributeFilterPillLabels({ + value: sample, + stateNamesById: new Map([ + ['s1', 'Todo'], + ['s2', 'In Progress'] + ]), + memberNamesById: new Map(), + labelNamesById: new Map([['l1', 'Bug']]) + }) + expect(pills.map((p) => p.key)).toEqual(['status', 'priority', 'assignee', 'labels']) + expect(pills[0]?.value).toContain('Todo') + expect(pills[2]?.value).toMatch(/Unassigned/i) + expect(pills[3]?.value).toBe('Bug') + }) +}) diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx new file mode 100644 index 00000000000..b8b74abb6e6 --- /dev/null +++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx @@ -0,0 +1,298 @@ +// Why: Linear Filters chrome mirrors GitHub PR filters — one outline button, +// sectioned popover, removable pills — without encoding facets into free-text search. +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { ListFilter, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { useTeamLabels, useTeamMembers, useTeamStates } from '@/hooks/useIssueMetadata' +import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client' +import { translate } from '@/i18n/i18n' +import { + canonicalizeLinearIssueAttributeFilter, + emptyLinearIssueAttributeFilter, + type LinearIssueAttributeFilter +} from '../../../shared/linear-issue-attribute-filter' +import type { LinearTeam } from '../../../shared/types' +import { + LinearIssueFilterSectionDetail, + LinearIssueFilterSectionMenu, + clearLinearIssueAttributeFacet, + countLinearIssueAttributeFilters, + linearIssueAttributeFilterPillLabels, + type LinearIssueFilterSectionKey +} from './linear-issue-attribute-filter-sections' + +type Props = { + value: LinearIssueAttributeFilter + onChange: (next: LinearIssueAttributeFilter) => void + workspaceId: string | null + isAllWorkspaces: boolean + primaryTeam: LinearTeam | null + selectedTeamCount: number + settings?: RuntimeLinearSettings +} + +function ActivePill({ + label, + value, + onClear +}: { + label: string + value: string + onClear: () => void +}): React.JSX.Element { + return ( + + {label}: + {value} + + + ) +} + +export default function LinearIssueAttributeFilterDropdowns({ + value, + onChange, + workspaceId, + isAllWorkspaces, + primaryTeam, + selectedTeamCount, + settings +}: Props): React.JSX.Element { + const [popoverOpen, setPopoverOpen] = useState(false) + const [openSection, setOpenSection] = useState(null) + + const activeTeamId = popoverOpen && !isAllWorkspaces ? (primaryTeam?.id ?? null) : null + const concreteWorkspaceId = + popoverOpen && !isAllWorkspaces && workspaceId && workspaceId !== 'all' ? workspaceId : null + + const states = useTeamStates(activeTeamId, settings, concreteWorkspaceId) + const labels = useTeamLabels(activeTeamId, settings, concreteWorkspaceId) + const members = useTeamMembers(activeTeamId, settings, concreteWorkspaceId) + + // Why: prune only after a successful non-empty metadata load for the same team; + // loading/error/empty-before-load must never clear active selections (R12). + const pruneTeamKeyRef = useRef(null) + useEffect(() => { + if (!activeTeamId || !concreteWorkspaceId) { + return + } + if (states.loading || labels.loading || members.loading) { + return + } + if (states.error || labels.error || members.error) { + return + } + if (states.data.length === 0 && labels.data.length === 0 && members.data.length === 0) { + return + } + const pruneKey = `${concreteWorkspaceId}::${activeTeamId}` + if (pruneTeamKeyRef.current === pruneKey) { + return + } + pruneTeamKeyRef.current = pruneKey + const stateIds = new Set(states.data.map((s) => s.id)) + const labelIds = new Set(labels.data.map((l) => l.id)) + const memberIds = new Set(members.data.map((m) => m.id)) + const next: LinearIssueAttributeFilter = { + ...value, + stateIds: value.stateIds.filter((id) => stateIds.has(id)), + labelIds: value.labelIds.filter((id) => labelIds.has(id)), + assignee: + value.assignee?.kind === 'user' && !memberIds.has(value.assignee.id) ? null : value.assignee + } + const canonicalNext = canonicalizeLinearIssueAttributeFilter(next) + const canonicalValue = canonicalizeLinearIssueAttributeFilter(value) + if (JSON.stringify(canonicalNext) !== JSON.stringify(canonicalValue)) { + onChange(canonicalNext) + } + }, [ + activeTeamId, + concreteWorkspaceId, + states.loading, + states.error, + states.data, + labels.loading, + labels.error, + labels.data, + members.loading, + members.error, + members.data, + value, + onChange + ]) + + const statusOptions = useMemo( + () => states.data.map((state) => ({ key: state.id, primary: state.name })), + [states.data] + ) + const labelOptions = useMemo( + () => labels.data.map((label) => ({ key: label.id, primary: label.name })), + [labels.data] + ) + const assigneeOptions = useMemo( + () => + members.data.map((member) => ({ + key: member.id, + primary: member.displayName || member.id + })), + [members.data] + ) + + const stateNamesById = useMemo( + () => new Map(states.data.map((state) => [state.id, state.name] as const)), + [states.data] + ) + const labelNamesById = useMemo( + () => new Map(labels.data.map((label) => [label.id, label.name] as const)), + [labels.data] + ) + const memberNamesById = useMemo( + () => + new Map(members.data.map((member) => [member.id, member.displayName || member.id] as const)), + [members.data] + ) + + const activeCount = countLinearIssueAttributeFilters(value) + const pills = linearIssueAttributeFilterPillLabels({ + value, + stateNamesById, + memberNamesById, + labelNamesById + }) + + const teamRequiredMessage = !primaryTeam + ? translate( + 'auto.components.linear-issue-attribute-filter-dropdowns.teamRequired', + 'Select a team to load status, assignees, and labels for this workspace.' + ) + : null + + return ( +
+ { + setPopoverOpen(open) + if (!open) { + setOpenSection(null) + } + }} + > + + + + + {isAllWorkspaces ? ( +
+

+ {translate( + 'auto.components.linear-issue-attribute-filter-dropdowns.allWorkspacesTitle', + 'Select one workspace' + )} +

+

+ {translate( + 'auto.components.linear-issue-attribute-filter-dropdowns.allWorkspacesBody', + 'Status, assignee, and label filters use ids from a single Linear workspace. Choose one workspace to filter by those attributes.' + )} +

+
+ ) : ( + <> + {selectedTeamCount > 1 && primaryTeam ? ( +

+ {translate( + 'auto.components.linear-issue-attribute-filter-dropdowns.optionsFromTeam', + 'Options from {{team}}', + { team: primaryTeam.name } + )} +

+ ) : null} + {openSection ? ( + onChange(canonicalizeLinearIssueAttributeFilter(next))} + statusOptions={statusOptions} + assigneeOptions={assigneeOptions} + labelOptions={labelOptions} + statusLoading={states.loading} + statusError={states.error} + assigneeLoading={members.loading} + assigneeError={members.error} + labelLoading={labels.loading} + labelError={labels.error} + teamRequiredMessage={teamRequiredMessage} + onBack={() => setOpenSection(null)} + /> + ) : ( + + )} + {activeCount > 0 ? ( +
+ +
+ ) : null} + + )} +
+
+ + {pills.map((pill) => ( + + onChange( + canonicalizeLinearIssueAttributeFilter( + clearLinearIssueAttributeFacet(value, pill.key) + ) + ) + } + /> + ))} +
+ ) +} diff --git a/src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts b/src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts new file mode 100644 index 00000000000..1b7cdf164d2 --- /dev/null +++ b/src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { resolveLinearIssueAttributeFilterPrimaryTeam } from './linear-issue-attribute-filter-primary-team' +import type { LinearTeam } from '../../../shared/types' + +const teams: LinearTeam[] = [ + { id: 't-b', name: 'Backend', key: 'BE' }, + { id: 't-a', name: 'App', key: 'APP' }, + { id: 't-c', name: 'Core', key: 'CORE' } +] + +describe('resolveLinearIssueAttributeFilterPrimaryTeam', () => { + it('picks the first available team by stable name/id when none selected', () => { + expect( + resolveLinearIssueAttributeFilterPrimaryTeam({ + selectedTeamIds: [], + availableTeams: teams + })?.id + ).toBe('t-a') + }) + + it('picks the first selected team by stable name/id order', () => { + expect( + resolveLinearIssueAttributeFilterPrimaryTeam({ + selectedTeamIds: ['t-c', 't-b'], + availableTeams: teams + })?.id + ).toBe('t-b') + }) + + it('falls back to first available when selected ids are unavailable', () => { + expect( + resolveLinearIssueAttributeFilterPrimaryTeam({ + selectedTeamIds: ['missing'], + availableTeams: teams + })?.id + ).toBe('t-a') + }) +}) diff --git a/src/renderer/src/components/linear-issue-attribute-filter-primary-team.ts b/src/renderer/src/components/linear-issue-attribute-filter-primary-team.ts new file mode 100644 index 00000000000..53fe674b04b --- /dev/null +++ b/src/renderer/src/components/linear-issue-attribute-filter-primary-team.ts @@ -0,0 +1,30 @@ +import type { LinearTeam } from '../../../shared/types' + +function compareTeamNameId(a: LinearTeam, b: LinearTeam): number { + const nameCmp = a.name.localeCompare(b.name) + if (nameCmp !== 0) { + return nameCmp + } + return a.id.localeCompare(b.id) +} + +/** Deterministic primary team: first selected by name/id, else first available. */ +export function resolveLinearIssueAttributeFilterPrimaryTeam(options: { + selectedTeamIds: string[] + availableTeams: LinearTeam[] +}): LinearTeam | null { + const { selectedTeamIds, availableTeams } = options + if (availableTeams.length === 0) { + return null + } + if (selectedTeamIds.length === 0) { + return [...availableTeams].sort(compareTeamNameId)[0] ?? null + } + const selected = availableTeams + .filter((team) => selectedTeamIds.includes(team.id)) + .sort(compareTeamNameId) + if (selected.length > 0) { + return selected[0] ?? null + } + return [...availableTeams].sort(compareTeamNameId)[0] ?? null +} diff --git a/src/renderer/src/components/linear-issue-attribute-filter-sections.tsx b/src/renderer/src/components/linear-issue-attribute-filter-sections.tsx new file mode 100644 index 00000000000..451a25a8ea9 --- /dev/null +++ b/src/renderer/src/components/linear-issue-attribute-filter-sections.tsx @@ -0,0 +1,377 @@ +import React from 'react' +import { ChevronRight } from 'lucide-react' +import { + MultiSelectList, + SingleSelectList, + type PickerOption +} from '@/components/github/PRFilterPickers' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + canonicalizeLinearIssueAttributeFilter, + type LinearIssueAttributeFilter +} from '../../../shared/linear-issue-attribute-filter' +import { getLinearPriorityLabel } from './task-page-localized-options' + +export type LinearIssueFilterSectionKey = 'status' | 'priority' | 'assignee' | 'labels' + +export function countLinearIssueAttributeFilters(value: LinearIssueAttributeFilter): number { + const canonical = canonicalizeLinearIssueAttributeFilter(value) + return ( + (canonical.stateIds.length > 0 ? 1 : 0) + + (canonical.priorities.length > 0 ? 1 : 0) + + (canonical.assignee ? 1 : 0) + + (canonical.labelIds.length > 0 ? 1 : 0) + ) +} + +export function clearLinearIssueAttributeFacet( + value: LinearIssueAttributeFilter, + facet: LinearIssueFilterSectionKey +): LinearIssueAttributeFilter { + switch (facet) { + case 'status': + return { ...value, stateIds: [] } + case 'priority': + return { ...value, priorities: [] } + case 'assignee': + return { ...value, assignee: null } + case 'labels': + return { ...value, labelIds: [] } + } +} + +export function linearIssueAttributeFilterPillLabels(options: { + value: LinearIssueAttributeFilter + stateNamesById: Map + memberNamesById: Map + labelNamesById: Map +}): { key: LinearIssueFilterSectionKey; label: string; value: string }[] { + const canonical = canonicalizeLinearIssueAttributeFilter(options.value) + const pills: { key: LinearIssueFilterSectionKey; label: string; value: string }[] = [] + if (canonical.stateIds.length > 0) { + pills.push({ + key: 'status', + label: translate('auto.components.linear-issue-attribute-filter-sections.status', 'Status'), + value: canonical.stateIds.map((id) => options.stateNamesById.get(id) ?? id).join(', ') + }) + } + if (canonical.priorities.length > 0) { + pills.push({ + key: 'priority', + label: translate( + 'auto.components.linear-issue-attribute-filter-sections.priority', + 'Priority' + ), + value: canonical.priorities.map((p) => getLinearPriorityLabel(p)).join(', ') + }) + } + if (canonical.assignee?.kind === 'unassigned') { + pills.push({ + key: 'assignee', + label: translate( + 'auto.components.linear-issue-attribute-filter-sections.assignee', + 'Assignee' + ), + value: translate( + 'auto.components.linear-issue-attribute-filter-sections.unassigned', + 'Unassigned' + ) + }) + } else if (canonical.assignee?.kind === 'user') { + pills.push({ + key: 'assignee', + label: translate( + 'auto.components.linear-issue-attribute-filter-sections.assignee', + 'Assignee' + ), + value: options.memberNamesById.get(canonical.assignee.id) ?? canonical.assignee.id + }) + } + if (canonical.labelIds.length > 0) { + pills.push({ + key: 'labels', + label: translate('auto.components.linear-issue-attribute-filter-sections.labels', 'Labels'), + value: canonical.labelIds.map((id) => options.labelNamesById.get(id) ?? id).join(', ') + }) + } + return pills +} + +function priorityOptions(): PickerOption[] { + return [0, 1, 2, 3, 4].map((priority) => ({ + key: String(priority), + primary: getLinearPriorityLabel(priority) + })) +} + +export function LinearIssueFilterSectionMenu({ + value, + onOpenSection +}: { + value: LinearIssueAttributeFilter + onOpenSection: (section: LinearIssueFilterSectionKey) => void +}): React.JSX.Element { + const sections: { key: LinearIssueFilterSectionKey; label: string; summary: string }[] = [ + { + key: 'status', + label: translate('auto.components.linear-issue-attribute-filter-sections.status', 'Status'), + summary: + value.stateIds.length > 0 + ? translate( + 'auto.components.linear-issue-attribute-filter-sections.countSelected', + '{{count}} selected', + { count: value.stateIds.length } + ) + : '' + }, + { + key: 'priority', + label: translate( + 'auto.components.linear-issue-attribute-filter-sections.priority', + 'Priority' + ), + summary: + value.priorities.length > 0 + ? translate( + 'auto.components.linear-issue-attribute-filter-sections.countSelected', + '{{count}} selected', + { count: value.priorities.length } + ) + : '' + }, + { + key: 'assignee', + label: translate( + 'auto.components.linear-issue-attribute-filter-sections.assignee', + 'Assignee' + ), + summary: value.assignee + ? value.assignee.kind === 'unassigned' + ? translate( + 'auto.components.linear-issue-attribute-filter-sections.unassigned', + 'Unassigned' + ) + : translate('auto.components.linear-issue-attribute-filter-sections.selected', 'selected') + : '' + }, + { + key: 'labels', + label: translate('auto.components.linear-issue-attribute-filter-sections.labels', 'Labels'), + summary: + value.labelIds.length > 0 + ? translate( + 'auto.components.linear-issue-attribute-filter-sections.countSelected', + '{{count}} selected', + { count: value.labelIds.length } + ) + : '' + } + ] + + return ( +
+ {sections.map((section) => ( + + ))} +
+ ) +} + +export function LinearIssueFilterSectionDetail({ + section, + value, + onChange, + statusOptions, + assigneeOptions, + labelOptions, + statusLoading, + statusError, + assigneeLoading, + assigneeError, + labelLoading, + labelError, + teamRequiredMessage, + onBack +}: { + section: LinearIssueFilterSectionKey + value: LinearIssueAttributeFilter + onChange: (next: LinearIssueAttributeFilter) => void + statusOptions: PickerOption[] + assigneeOptions: PickerOption[] + labelOptions: PickerOption[] + statusLoading: boolean + statusError: string | null + assigneeLoading: boolean + assigneeError: string | null + labelLoading: boolean + labelError: string | null + teamRequiredMessage: string | null + onBack: () => void +}): React.JSX.Element { + if (section === 'priority') { + return ( +
+ + + onChange({ + ...value, + priorities: keys + .map((key) => Number.parseInt(key, 10)) + .filter((n) => Number.isInteger(n) && n >= 0 && n <= 4) + }) + } + /> +
+ ) + } + + if ( + teamRequiredMessage && + (section === 'status' || section === 'labels' || section === 'assignee') + ) { + return ( +
+ + {section === 'assignee' ? ( +
+ +
+ ) : null} +

{teamRequiredMessage}

+
+ ) + } + + if (section === 'status') { + return ( +
+ + onChange({ ...value, stateIds })} + /> +
+ ) + } + + if (section === 'labels') { + return ( +
+ + onChange({ ...value, labelIds })} + /> +
+ ) + } + + const activeAssignee = + value.assignee?.kind === 'unassigned' + ? '__unassigned__' + : value.assignee?.kind === 'user' + ? value.assignee.id + : null + + return ( +
+ + { + if (!key) { + onChange({ ...value, assignee: null }) + return + } + if (key === '__unassigned__') { + onChange({ ...value, assignee: { kind: 'unassigned' } }) + return + } + onChange({ ...value, assignee: { kind: 'user', id: key } }) + }} + /> +
+ ) +} + +function SectionBack({ onBack }: { onBack: () => void }): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index f508faebd82..c17ea30c173 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -853,9 +853,10 @@ export default function SmartWorkspaceNameField({ const trimmed = debouncedQuery.trim() const request = trimmed ? searchLinearIssues(trimmed, RESULT_LIMIT, { sourceContext: linearSourceContext }) - : listLinearIssues('assigned', RESULT_LIMIT, { sourceContext: linearSourceContext }).then( - (result) => result.items - ) + : listLinearIssues( + { kind: 'list', filter: 'assigned', limit: RESULT_LIMIT }, + { sourceContext: linearSourceContext } + ).then((result) => result.items) void request .then((issues) => { if (!stale) { diff --git a/src/renderer/src/components/task-page-linear-issue-empty-state.test.ts b/src/renderer/src/components/task-page-linear-issue-empty-state.test.ts new file mode 100644 index 00000000000..914d24db855 --- /dev/null +++ b/src/renderer/src/components/task-page-linear-issue-empty-state.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { + resolveLinearIssueEmptyKind, + shouldOfferLinearIssueFetchMore +} from './task-page-linear-issue-empty-state' +import { emptyLinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter' + +describe('task-page-linear-issue-empty-state', () => { + it('prefers context, then search, then server filter, then client team, then unfiltered', () => { + expect( + resolveLinearIssueEmptyKind({ + hasContextLabel: true, + searchActive: true, + attributeFilter: emptyLinearIssueAttributeFilter(), + serverIssueCount: 0, + filteredIssueCount: 0 + }) + ).toBe('context') + + expect( + resolveLinearIssueEmptyKind({ + hasContextLabel: false, + searchActive: true, + attributeFilter: { + stateIds: ['s'], + priorities: [], + assignee: null, + labelIds: [] + }, + serverIssueCount: 0, + filteredIssueCount: 0 + }) + ).toBe('search') + + expect( + resolveLinearIssueEmptyKind({ + hasContextLabel: false, + searchActive: false, + attributeFilter: { + stateIds: ['s'], + priorities: [], + assignee: null, + labelIds: [] + }, + serverIssueCount: 0, + filteredIssueCount: 0 + }) + ).toBe('server-attribute-filter') + + expect( + resolveLinearIssueEmptyKind({ + hasContextLabel: false, + searchActive: false, + attributeFilter: emptyLinearIssueAttributeFilter(), + serverIssueCount: 3, + filteredIssueCount: 0 + }) + ).toBe('client-team') + + expect( + resolveLinearIssueEmptyKind({ + hasContextLabel: false, + searchActive: false, + attributeFilter: emptyLinearIssueAttributeFilter(), + serverIssueCount: 0, + filteredIssueCount: 0 + }) + ).toBe('unfiltered-scope') + }) + + it('offers fetch more only for client-team thinning with server hasMore', () => { + expect(shouldOfferLinearIssueFetchMore({ emptyKind: 'client-team', serverHasMore: true })).toBe( + true + ) + expect( + shouldOfferLinearIssueFetchMore({ + emptyKind: 'server-attribute-filter', + serverHasMore: true + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/task-page-linear-issue-empty-state.ts b/src/renderer/src/components/task-page-linear-issue-empty-state.ts new file mode 100644 index 00000000000..a62370a1130 --- /dev/null +++ b/src/renderer/src/components/task-page-linear-issue-empty-state.ts @@ -0,0 +1,46 @@ +import { + isEmptyLinearIssueAttributeFilter, + type LinearIssueAttributeFilter +} from '../../../shared/linear-issue-attribute-filter' + +export type LinearIssueEmptyKind = + | 'context' + | 'search' + | 'server-attribute-filter' + | 'client-team' + | 'unfiltered-scope' + | null + +export function resolveLinearIssueEmptyKind(options: { + hasContextLabel: boolean + searchActive: boolean + attributeFilter: LinearIssueAttributeFilter + serverIssueCount: number + filteredIssueCount: number +}): LinearIssueEmptyKind { + if (options.serverIssueCount > 0 && options.filteredIssueCount === 0) { + return 'client-team' + } + if (options.serverIssueCount > 0) { + return null + } + if (options.hasContextLabel) { + return 'context' + } + if (options.searchActive) { + return 'search' + } + if (!isEmptyLinearIssueAttributeFilter(options.attributeFilter)) { + return 'server-attribute-filter' + } + return 'unfiltered-scope' +} + +export function shouldOfferLinearIssueFetchMore(options: { + emptyKind: LinearIssueEmptyKind + serverHasMore: boolean +}): boolean { + // Why: Fetch more is only honest for client-team thinning of a larger server + // window. Server-filtered empties must not invite more pages of the same empty set. + return options.emptyKind === 'client-team' && options.serverHasMore +} diff --git a/src/renderer/src/components/task-page-linear-issue-request.test.ts b/src/renderer/src/components/task-page-linear-issue-request.test.ts new file mode 100644 index 00000000000..71cdbca617f --- /dev/null +++ b/src/renderer/src/components/task-page-linear-issue-request.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { + buildLinearIssueListReadArgs, + buildLinearIssueListRequestSignature, + isLinearIssueSearchActive, + shouldForceLinearIssueListRead, + teamDerivedFacetsForPrimaryTeamChange +} from './task-page-linear-issue-request' +import type { LinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter' + +const filter: LinearIssueAttributeFilter = { + stateIds: ['s1'], + priorities: [1], + assignee: { kind: 'unassigned' }, + labelIds: ['l1'] +} + +describe('task-page-linear-issue-request', () => { + it('treats immediate or applied search as active', () => { + expect(isLinearIssueSearchActive('q', '')).toBe(true) + expect(isLinearIssueSearchActive('', 'q')).toBe(true) + expect(isLinearIssueSearchActive(' ', ' ')).toBe(false) + }) + + it('omits attribute filters from list read args while search is active', () => { + expect( + buildLinearIssueListReadArgs({ + limit: 36, + attributeFilter: filter, + searchActive: true + }).attributeFilter + ).toBeUndefined() + expect( + buildLinearIssueListReadArgs({ + limit: 36, + attributeFilter: filter, + searchActive: false + }).attributeFilter + ).toEqual(filter) + }) + + it('includes source scope and canonical signature in the request identity', () => { + const signature = buildLinearIssueListRequestSignature({ + workspaceId: 'ws-1', + limit: 36, + attributeFilter: filter + }) + expect(signature).toContain('local::ws-1::list::all::36::') + expect(signature).toContain('"stateIds":["s1"]') + }) + + it('forces a read when the filter signature changes', () => { + expect( + shouldForceLinearIssueListRead({ + previousFilterSignature: 'a', + nextFilterSignature: 'b', + refreshForced: false + }) + ).toBe(true) + expect( + shouldForceLinearIssueListRead({ + previousFilterSignature: 'a', + nextFilterSignature: 'a', + refreshForced: false + }) + ).toBe(false) + }) + + it('clears team-derived facets while preserving priority', () => { + expect(teamDerivedFacetsForPrimaryTeamChange(filter)).toEqual({ + stateIds: [], + priorities: [1], + assignee: null, + labelIds: [] + }) + }) +}) diff --git a/src/renderer/src/components/task-page-linear-issue-request.ts b/src/renderer/src/components/task-page-linear-issue-request.ts new file mode 100644 index 00000000000..ca19149ff33 --- /dev/null +++ b/src/renderer/src/components/task-page-linear-issue-request.ts @@ -0,0 +1,80 @@ +import { + isEmptyLinearIssueAttributeFilter, + linearIssueAttributeFilterSignature, + type LinearIssueAttributeFilter +} from '../../../shared/linear-issue-attribute-filter' +import { + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../shared/task-source-context' +import type { LinearIssueListReadArgs } from '../store/slices/linear' + +export function isLinearIssueSearchActive(immediateQuery: string, appliedQuery: string): boolean { + return immediateQuery.trim().length > 0 || appliedQuery.trim().length > 0 +} + +export function buildLinearIssueListReadArgs(options: { + filter?: 'assigned' | 'created' | 'all' | 'completed' + limit: number + attributeFilter: LinearIssueAttributeFilter + searchActive: boolean + /** Concrete workspace only; `all` must never send workspace-scoped facet ids. */ + allowAttributeFilter?: boolean +}): LinearIssueListReadArgs { + const attributeFilter = + options.searchActive || + options.allowAttributeFilter === false || + isEmptyLinearIssueAttributeFilter(options.attributeFilter) + ? undefined + : options.attributeFilter + return { + kind: 'list', + filter: options.filter ?? 'all', + limit: options.limit, + attributeFilter + } +} + +export function buildLinearIssueListRequestSignature(options: { + sourceContext?: TaskSourceContext | null + workspaceId: string | null | undefined + filter?: 'assigned' | 'created' | 'all' | 'completed' + limit: number + attributeFilter: LinearIssueAttributeFilter + searchQuery?: string +}): string { + const sourceScope = options.sourceContext + ? getTaskSourceCacheScope(options.sourceContext) + : 'local' + const workspace = options.workspaceId ?? 'default' + if (options.searchQuery && options.searchQuery.trim().length > 0) { + return `${sourceScope}::${workspace}::search::${options.searchQuery.trim()}` + } + const signature = linearIssueAttributeFilterSignature(options.attributeFilter) + return `${sourceScope}::${workspace}::list::${options.filter ?? 'all'}::${options.limit}::${signature}` +} + +export function shouldForceLinearIssueListRead(options: { + previousFilterSignature: string + nextFilterSignature: string + refreshForced: boolean +}): boolean { + if (options.refreshForced) { + return true + } + // Why: filter signature changes always need a current server read, even when + // returning to a previously cached signature that is still warm. + return options.previousFilterSignature !== options.nextFilterSignature +} + +export function teamDerivedFacetsForPrimaryTeamChange( + current: LinearIssueAttributeFilter +): LinearIssueAttributeFilter { + // Why: status/assignee/labels are team-scoped ids; priority is global 0..4. + return { + stateIds: [], + priorities: current.priorities, + assignee: null, + labelIds: [] + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5cca93854e2..a4f1095d876 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1731,7 +1731,10 @@ "searchIssues": "Search issues", "useIssueNumber": "Use issue #{{value0}}", "noMatchingIssuesLoaded": "No matching issues loaded.", - "editReviewersWithCurrent": "Edit reviewers: {{value0}}" + "editReviewersWithCurrent": "Edit reviewers: {{value0}}", + "linearEmptyAttributeFilter": "No issues match the selected filters. Clear a filter or try different criteria.", + "linearEmptyUnfilteredScope": "No issues in this workspace scope. Try searching or adjusting teams.", + "linearFetchMore": "Fetch more" }, "Terminal": { "73768427cf": "Close", @@ -12754,6 +12757,29 @@ } } } + }, + "linear-issue-attribute-filter-dropdowns": { + "removeFilter": "Remove {{value0}} filter", + "teamRequired": "Select a team to load status, assignees, and labels for this workspace.", + "filters": "Filters", + "allWorkspacesTitle": "Select one workspace", + "allWorkspacesBody": "Status, assignee, and label filters use ids from a single Linear workspace. Choose one workspace to filter by those attributes.", + "optionsFromTeam": "Options from {{team}}", + "clearAll": "Clear all filters" + }, + "linear-issue-attribute-filter-sections": { + "status": "Status", + "priority": "Priority", + "assignee": "Assignee", + "unassigned": "Unassigned", + "labels": "Labels", + "countSelected": "{{count}} selected", + "selected": "selected", + "searchPriority": "Filter priority…", + "searchStatus": "Filter status…", + "searchLabels": "Filter labels…", + "searchAssignee": "Filter assignee…", + "back": "Back" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f7198149721..9485e8f9568 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1731,7 +1731,10 @@ "searchIssues": "Buscar issues", "useIssueNumber": "Usar issue #{{value0}}", "noMatchingIssuesLoaded": "No se cargaron issues coincidentes.", - "editReviewersWithCurrent": "Editar revisores: {{value0}}" + "editReviewersWithCurrent": "Editar revisores: {{value0}}", + "linearEmptyAttributeFilter": "No issues match the selected filters. Clear a filter or try different criteria.", + "linearEmptyUnfilteredScope": "No issues in this workspace scope. Try searching or adjusting teams.", + "linearFetchMore": "Fetch more" }, "Terminal": { "73768427cf": "Cerrar", @@ -12754,6 +12757,29 @@ } } } + }, + "linear-issue-attribute-filter-dropdowns": { + "removeFilter": "Remove {{value0}} filter", + "teamRequired": "Select a team to load status, assignees, and labels for this workspace.", + "filters": "Filters", + "allWorkspacesTitle": "Select one workspace", + "allWorkspacesBody": "Status, assignee, and label filters use ids from a single Linear workspace. Choose one workspace to filter by those attributes.", + "optionsFromTeam": "Options from {{team}}", + "clearAll": "Clear all filters" + }, + "linear-issue-attribute-filter-sections": { + "status": "Status", + "priority": "Priority", + "assignee": "Assignee", + "unassigned": "Unassigned", + "labels": "Labels", + "countSelected": "{{count}} selected", + "selected": "selected", + "searchPriority": "Filter priority…", + "searchStatus": "Filter status…", + "searchLabels": "Filter labels…", + "searchAssignee": "Filter assignee…", + "back": "Back" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index cb50770b696..bf957a82d0d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1731,7 +1731,10 @@ "searchIssues": "Issue を検索", "useIssueNumber": "Issue #{{value0}} を使用", "noMatchingIssuesLoaded": "一致する issue は読み込まれていません。", - "editReviewersWithCurrent": "レビュアーを編集: {{value0}}" + "editReviewersWithCurrent": "レビュアーを編集: {{value0}}", + "linearEmptyAttributeFilter": "No issues match the selected filters. Clear a filter or try different criteria.", + "linearEmptyUnfilteredScope": "No issues in this workspace scope. Try searching or adjusting teams.", + "linearFetchMore": "Fetch more" }, "Terminal": { "73768427cf": "閉じる", @@ -12754,6 +12757,29 @@ } } } + }, + "linear-issue-attribute-filter-dropdowns": { + "removeFilter": "Remove {{value0}} filter", + "teamRequired": "Select a team to load status, assignees, and labels for this workspace.", + "filters": "Filters", + "allWorkspacesTitle": "Select one workspace", + "allWorkspacesBody": "Status, assignee, and label filters use ids from a single Linear workspace. Choose one workspace to filter by those attributes.", + "optionsFromTeam": "Options from {{team}}", + "clearAll": "Clear all filters" + }, + "linear-issue-attribute-filter-sections": { + "status": "Status", + "priority": "Priority", + "assignee": "Assignee", + "unassigned": "Unassigned", + "labels": "Labels", + "countSelected": "{{count}} selected", + "selected": "selected", + "searchPriority": "Filter priority…", + "searchStatus": "Filter status…", + "searchLabels": "Filter labels…", + "searchAssignee": "Filter assignee…", + "back": "Back" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index fc9915e9b2d..f362d26f46d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1731,7 +1731,10 @@ "searchIssues": "이슈 검색", "useIssueNumber": "이슈 #{{value0}} 사용", "noMatchingIssuesLoaded": "일치하는 이슈를 불러오지 않았습니다.", - "editReviewersWithCurrent": "리뷰어 편집: {{value0}}" + "editReviewersWithCurrent": "리뷰어 편집: {{value0}}", + "linearEmptyAttributeFilter": "No issues match the selected filters. Clear a filter or try different criteria.", + "linearEmptyUnfilteredScope": "No issues in this workspace scope. Try searching or adjusting teams.", + "linearFetchMore": "Fetch more" }, "Terminal": { "73768427cf": "닫기", @@ -12754,6 +12757,29 @@ } } } + }, + "linear-issue-attribute-filter-dropdowns": { + "removeFilter": "Remove {{value0}} filter", + "teamRequired": "Select a team to load status, assignees, and labels for this workspace.", + "filters": "Filters", + "allWorkspacesTitle": "Select one workspace", + "allWorkspacesBody": "Status, assignee, and label filters use ids from a single Linear workspace. Choose one workspace to filter by those attributes.", + "optionsFromTeam": "Options from {{team}}", + "clearAll": "Clear all filters" + }, + "linear-issue-attribute-filter-sections": { + "status": "Status", + "priority": "Priority", + "assignee": "Assignee", + "unassigned": "Unassigned", + "labels": "Labels", + "countSelected": "{{count}} selected", + "selected": "selected", + "searchPriority": "Filter priority…", + "searchStatus": "Filter status…", + "searchLabels": "Filter labels…", + "searchAssignee": "Filter assignee…", + "back": "Back" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 55c9d06a62f..be29a053cc0 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1731,7 +1731,10 @@ "searchIssues": "搜索议题", "useIssueNumber": "使用议题 #{{value0}}", "noMatchingIssuesLoaded": "没有加载到匹配的议题。", - "editReviewersWithCurrent": "编辑评审人:{{value0}}" + "editReviewersWithCurrent": "编辑评审人:{{value0}}", + "linearEmptyAttributeFilter": "No issues match the selected filters. Clear a filter or try different criteria.", + "linearEmptyUnfilteredScope": "No issues in this workspace scope. Try searching or adjusting teams.", + "linearFetchMore": "Fetch more" }, "Terminal": { "73768427cf": "关闭", @@ -12754,6 +12757,29 @@ } } } + }, + "linear-issue-attribute-filter-dropdowns": { + "removeFilter": "Remove {{value0}} filter", + "teamRequired": "Select a team to load status, assignees, and labels for this workspace.", + "filters": "Filters", + "allWorkspacesTitle": "Select one workspace", + "allWorkspacesBody": "Status, assignee, and label filters use ids from a single Linear workspace. Choose one workspace to filter by those attributes.", + "optionsFromTeam": "Options from {{team}}", + "clearAll": "Clear all filters" + }, + "linear-issue-attribute-filter-sections": { + "status": "Status", + "priority": "Priority", + "assignee": "Assignee", + "unassigned": "Unassigned", + "labels": "Labels", + "countSelected": "{{count}} selected", + "selected": "selected", + "searchPriority": "Filter priority…", + "searchStatus": "Filter status…", + "searchLabels": "Filter labels…", + "searchAssignee": "Filter assignee…", + "back": "Back" } }, "i18n": { diff --git a/src/renderer/src/runtime/runtime-linear-client.ts b/src/renderer/src/runtime/runtime-linear-client.ts index 09fd8721cb3..04692269ec2 100644 --- a/src/renderer/src/runtime/runtime-linear-client.ts +++ b/src/renderer/src/runtime/runtime-linear-client.ts @@ -25,6 +25,11 @@ import { type TaskSourceContext } from '../../../shared/task-source-context' import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds' +import type { LinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter' +import { + canonicalizeLinearIssueAttributeFilter, + isEmptyLinearIssueAttributeFilter +} from '../../../shared/linear-issue-attribute-filter' export type RuntimeLinearSettings = | Pick @@ -189,22 +194,26 @@ export async function linearListIssues( settings: RuntimeLinearSettings, filter?: LinearIssueFilter, limit?: number, - workspaceId?: LinearWorkspaceSelection | null + workspaceId?: LinearWorkspaceSelection | null, + attributeFilter?: LinearIssueAttributeFilter | null ): Promise> { const target = getLinearRuntimeTarget(settings) + const canonicalAttributeFilter = + attributeFilter && !isEmptyLinearIssueAttributeFilter(attributeFilter) + ? canonicalizeLinearIssueAttributeFilter(attributeFilter) + : undefined + const payload = { + filter, + limit, + workspaceId: workspaceId ?? undefined, + ...(canonicalAttributeFilter ? { attributeFilter: canonicalAttributeFilter } : {}) + } const result = target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'linear.listIssues', - { filter, limit, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : await window.api.linear.listIssues({ - filter, - limit, - workspaceId: workspaceId ?? undefined + ? await callRuntimeRpc(target, 'linear.listIssues', payload, { + timeoutMs: 30_000 }) + : await window.api.linear.listIssues(payload) return normalizeLinearIssueCollectionResult(result) } diff --git a/src/renderer/src/store/slices/linear-invalidation.test.ts b/src/renderer/src/store/slices/linear-invalidation.test.ts index aaf11764e8c..fdea0a7d19e 100644 --- a/src/renderer/src/store/slices/linear-invalidation.test.ts +++ b/src/renderer/src/store/slices/linear-invalidation.test.ts @@ -104,7 +104,7 @@ describe('createLinearSlice invalidation', () => { store.setState({ linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' }, linearListCache: { - 'workspace-1::list::all::36': { data: { items: [issue('LIST')] }, fetchedAt: Date.now() } + 'workspace-1::list::all::36::': { data: { items: [issue('LIST')] }, fetchedAt: Date.now() } } }) linearSearchIssues.mockResolvedValueOnce([issue('SEARCH')]) @@ -161,7 +161,7 @@ describe('createLinearSlice invalidation', () => { 'workspace-1::search::issue::20': { data: [issue('issue-id')], fetchedAt: Date.now() } }, linearListCache: { - 'workspace-1::list::all::36': { + 'workspace-1::list::all::36::': { data: { items: [issue('issue-id')], hasMore: false }, fetchedAt: Date.now() } @@ -197,7 +197,7 @@ describe('createLinearSlice invalidation', () => { 'workspace-1::issue-1': { data: issue('issue-1'), fetchedAt: Date.now() } }, linearListCache: { - 'workspace-1::list::all::36': { + 'workspace-1::list::all::36::': { data: { items: [issue('LIN-CACHED')] }, fetchedAt: Date.now() } @@ -259,7 +259,7 @@ describe('createLinearSlice invalidation', () => { store.setState({ linearStatus: status('workspace-1', 'Old Org'), linearListCache: { - 'workspace-1::list::all::36': { + 'workspace-1::list::all::36::': { data: { items: [issue('LIN-CACHED')] }, fetchedAt: Date.now() } @@ -306,7 +306,9 @@ describe('createLinearSlice invalidation', () => { linearListIssues.mockRejectedValueOnce(new Error('401 unauthorized')) linearStatus.mockResolvedValueOnce(status('workspace-2', 'Beta')) - await expect(store.getState().listLinearIssues('all', 36, { force: true })).resolves.toEqual({ + await expect( + store.getState().listLinearIssues({ kind: 'list', filter: 'all', limit: 36 }, { force: true }) + ).resolves.toEqual({ items: [] }) expect(store.getState().linearStatus.connected).toBe(true) diff --git a/src/renderer/src/store/slices/linear.test.ts b/src/renderer/src/store/slices/linear.test.ts index 190e60773b4..ca342f79f6f 100644 --- a/src/renderer/src/store/slices/linear.test.ts +++ b/src/renderer/src/store/slices/linear.test.ts @@ -130,19 +130,73 @@ describe('createLinearSlice caching', () => { .mockResolvedValueOnce({ items: [issue('LIN-1')] }) .mockResolvedValueOnce({ items: [issue('LIN-2')] }) - await expect(store.getState().listLinearIssues('all', 36)).resolves.toMatchObject({ - items: [{ id: 'LIN-1' }] - }) - await expect(store.getState().listLinearIssues('all', 36)).resolves.toMatchObject({ + await expect( + store.getState().listLinearIssues({ kind: 'list', filter: 'all', limit: 36 }) + ).resolves.toMatchObject({ items: [{ id: 'LIN-1' }] }) await expect( - store.getState().listLinearIssues('all', 36, { force: true }) + store.getState().listLinearIssues({ kind: 'list', filter: 'all', limit: 36 }) + ).resolves.toMatchObject({ + items: [{ id: 'LIN-1' }] + }) + await expect( + store.getState().listLinearIssues({ kind: 'list', filter: 'all', limit: 36 }, { force: true }) ).resolves.toMatchObject({ items: [{ id: 'LIN-2' }] }) expect(linearListIssues).toHaveBeenCalledTimes(2) }) + it('isolates list cache entries by attribute filter signature', async () => { + const store = createTestStore() + store.setState({ + linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' } + }) + const filtered = { + stateIds: ['state-1'], + priorities: [1], + assignee: null as null, + labelIds: [] as string[] + } + linearListIssues + .mockResolvedValueOnce({ items: [issue('LIN-ALL')] }) + .mockResolvedValueOnce({ items: [issue('LIN-FILTERED')] }) + + await store.getState().listLinearIssues({ kind: 'list', filter: 'all', limit: 36 }) + await store.getState().listLinearIssues({ + kind: 'list', + filter: 'all', + limit: 36, + attributeFilter: filtered + }) + + expect(linearListIssues).toHaveBeenCalledTimes(2) + expect( + store.getState().getCachedLinearIssues({ kind: 'list', filter: 'all', limit: 36 }) + ).toMatchObject({ items: [{ id: 'LIN-ALL' }] }) + expect( + store.getState().getCachedLinearIssues({ + kind: 'list', + filter: 'all', + limit: 36, + attributeFilter: filtered + }) + ).toMatchObject({ items: [{ id: 'LIN-FILTERED' }] }) + + store.getState().invalidateLinearIssueLists() + expect( + store.getState().getCachedLinearIssues({ + kind: 'list', + filter: 'all', + limit: 36, + attributeFilter: filtered + }) + ).toBeNull() + expect( + store.getState().getCachedLinearIssues({ kind: 'list', filter: 'all', limit: 36 }) + ).toMatchObject({ items: [{ id: 'LIN-ALL' }] }) + }) + it('lets forced list refresh bypass older in-flight reads without stale cache overwrite', async () => { const store = createTestStore() store.setState({ @@ -154,8 +208,12 @@ describe('createLinearSlice caching', () => { .mockReturnValueOnce(staleRequest.promise) .mockReturnValueOnce(forcedRequest.promise) - const stalePromise = store.getState().listLinearIssues('all', 36) - const forcedPromise = store.getState().listLinearIssues('all', 36, { force: true }) + const stalePromise = store + .getState() + .listLinearIssues({ kind: 'list', filter: 'all', limit: 36 }) + const forcedPromise = store + .getState() + .listLinearIssues({ kind: 'list', filter: 'all', limit: 36 }, { force: true }) expect(linearListIssues).toHaveBeenCalledTimes(2) @@ -206,13 +264,13 @@ describe('createLinearSlice caching', () => { store.setState({ linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' }, linearListCache: { - 'workspace-1::list::all::36': { data: { items: [issue('LIN-CACHED')] }, fetchedAt: 1 } + 'workspace-1::list::all::36::': { data: { items: [issue('LIN-CACHED')] }, fetchedAt: 1 } } }) linearListIssues.mockRejectedValueOnce(new Error('network down')) await expect( - store.getState().listLinearIssues('all', 36, { force: true }) + store.getState().listLinearIssues({ kind: 'list', filter: 'all', limit: 36 }, { force: true }) ).resolves.toMatchObject({ items: [{ id: 'LIN-CACHED' }] }) }) @@ -222,7 +280,7 @@ describe('createLinearSlice caching', () => { store.setState({ linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' }, linearListCache: { - 'workspace-1::list::all::36': { data: { items: [issue('LIN-CACHED')] }, fetchedAt: 1 } + 'workspace-1::list::all::36::': { data: { items: [issue('LIN-CACHED')] }, fetchedAt: 1 } } }) linearStatus.mockResolvedValue({ @@ -233,7 +291,7 @@ describe('createLinearSlice caching', () => { linearListIssues.mockRejectedValueOnce(error) await expect( - store.getState().listLinearIssues('all', 36, { force: true }) + store.getState().listLinearIssues({ kind: 'list', filter: 'all', limit: 36 }, { force: true }) ).resolves.toMatchObject({ items: [] }) expect(linearStatus).toHaveBeenCalled() }) @@ -274,7 +332,7 @@ describe('createLinearSlice caching', () => { }) await expect( - store.getState().listLinearIssues('all', 36, { force: true }) + store.getState().listLinearIssues({ kind: 'list', filter: 'all', limit: 36 }, { force: true }) ).resolves.toMatchObject({ items: [{ id: 'LIN-OK' }] }) await vi.waitFor(() => { expect(store.getState().linearStatus.credentialError).toBeUndefined() @@ -725,7 +783,7 @@ describe('createLinearSlice caching', () => { store.setState({ linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' }, linearListCache: { - 'workspace-1::list::all::36': { data: { items: [issue('LIN-1')] }, fetchedAt: 1 } + 'workspace-1::list::all::36::': { data: { items: [issue('LIN-1')] }, fetchedAt: 1 } } }) @@ -739,7 +797,7 @@ describe('createLinearSlice caching', () => { store.setState({ linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' }, linearListCache: { - 'workspace-1::list::all::36': { data: { items: [issue('LIST')] }, fetchedAt: Date.now() } + 'workspace-1::list::all::36::': { data: { items: [issue('LIST')] }, fetchedAt: Date.now() } } }) linearSearchIssues.mockResolvedValueOnce([issue('SEARCH')]) @@ -781,12 +839,20 @@ describe('createLinearSlice caching', () => { const sourceResult = deferred>() linearListIssues.mockReturnValueOnce(sourceResult.promise) - const request = store.getState().listLinearIssues('all', 36, { sourceContext }) + const request = store + .getState() + .listLinearIssues({ kind: 'list', filter: 'all', limit: 36 }, { sourceContext }) store.setState({ settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as never }) sourceResult.resolve({ items: [issue('LIN-SOURCE')] }) await expect(request).resolves.toMatchObject({ items: [{ id: 'LIN-SOURCE' }] }) - expect(linearListIssues).toHaveBeenCalledWith(sourceContext, 'all', 36, 'workspace-1') + expect(linearListIssues).toHaveBeenCalledWith( + sourceContext, + 'all', + 36, + 'workspace-1', + undefined + ) expect( store .getState() @@ -860,7 +926,7 @@ describe('createLinearSlice caching', () => { 'workspace-1::issue-id': { data: issue('issue-id'), fetchedAt: Date.now() } }, linearListCache: { - 'workspace-1::list::all::36': { + 'workspace-1::list::all::36::': { data: { items: [issue('issue-id')] }, fetchedAt: Date.now() } @@ -884,7 +950,7 @@ describe('createLinearSlice caching', () => { expect(store.getState().linearIssueCache['workspace-1::issue-id'].data?.title).toBe('Updated') expect(store.getState().linearIssueCache['workspace-1::issue-id'].fetchedAt).toBe(0) expect( - store.getState().linearListCache['workspace-1::list::all::36'].data?.items[0]?.title + store.getState().linearListCache['workspace-1::list::all::36::'].data?.items[0]?.title ).toBe('Updated') expect( store.getState().linearProjectIssueCache['workspace-1::project-issues::project-1::20'].data @@ -1065,9 +1131,13 @@ describe('createLinearSlice', () => { const store = createTestStore() store.setState({ linearStatus: { connected: true, viewer: null } }) - const localRequest = store.getState().listLinearIssues('assigned', 20) + const localRequest = store + .getState() + .listLinearIssues({ kind: 'list', filter: 'assigned', limit: 20 }) store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-1' } as never }) - const remoteRequest = store.getState().listLinearIssues('assigned', 20) + const remoteRequest = store + .getState() + .listLinearIssues({ kind: 'list', filter: 'assigned', limit: 20 }) remoteList.resolve({ items: [issue('LIN-REMOTE')] }) await remoteRequest diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 795d95605c5..e62a883b5b4 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -47,6 +47,12 @@ import { getTaskSourceRuntimeSettings, type TaskSourceContext } from '../../../../shared/task-source-context' +import { + canonicalizeLinearIssueAttributeFilter, + isEmptyLinearIssueAttributeFilter, + linearIssueAttributeFilterSignature, + type LinearIssueAttributeFilter +} from '../../../../shared/linear-issue-attribute-filter' const CACHE_TTL = 60_000 // 60s — same as GitHub work-items revalidation TTL const TEAM_CACHE_TTL = 10 * 60_000 // Teams change rarely and block visible Linear rows. @@ -194,9 +200,19 @@ function linearSearchCacheKey( function linearListCacheKey( workspaceId: LinearWorkspaceSelection | null | undefined, filter: 'assigned' | 'created' | 'all' | 'completed', - limit: number + limit: number, + attributeFilter?: LinearIssueAttributeFilter | null ): string { - return `${workspaceId ?? 'default'}::list::${filter}::${limit}` + const attributeSignature = linearIssueAttributeFilterSignature(attributeFilter) + return `${workspaceId ?? 'default'}::list::${filter}::${limit}::${attributeSignature}` +} + +// Why: facet mutations need one source-scoped token TaskPage can observe without +// scattering membership guesses through board/detail components. +const LINEAR_LIST_INVALIDATION_VERSION_CAP = 10_000 +let linearListInvalidationToken: { scope: string; version: number } = { + scope: '', + version: 0 } function linearTeamsCacheKey(workspaceId: LinearWorkspaceSelection | null | undefined): string { @@ -355,13 +371,29 @@ function patchLinearIssueCollectionCache( return { cache: nextCache, changed } } -type LinearIssueReadArgs = +export type LinearIssueListReadArgs = { + kind: 'list' + filter?: 'assigned' | 'created' | 'all' | 'completed' + limit?: number + attributeFilter?: LinearIssueAttributeFilter | null +} + +export type LinearIssueReadArgs = | { kind: 'search'; query: string; limit?: number } - | { kind: 'list'; filter?: 'assigned' | 'created' | 'all' | 'completed'; limit?: number } + | LinearIssueListReadArgs type LinearFetchOptions = { force?: boolean; sourceContext?: TaskSourceContext | null } type LinearPatchOptions = { sourceContext?: TaskSourceContext | null } +function normalizeListAttributeFilter( + attributeFilter?: LinearIssueAttributeFilter | null +): LinearIssueAttributeFilter | undefined { + if (!attributeFilter || isEmptyLinearIssueAttributeFilter(attributeFilter)) { + return undefined + } + return canonicalizeLinearIssueAttributeFilter(attributeFilter) +} + type LinearReadScope = { settings: AppState['settings'] | TaskSourceContext | null contextKey: string @@ -475,10 +507,11 @@ export type LinearSlice = { options?: LinearFetchOptions ) => Promise listLinearIssues: ( - filter?: 'assigned' | 'created' | 'all' | 'completed', - limit?: number, + args: LinearIssueListReadArgs, options?: LinearFetchOptions ) => Promise> + linearListInvalidationToken: { scope: string; version: number } + invalidateLinearIssueLists: (options?: Pick) => void getCachedLinearTeams: ( workspaceId?: LinearWorkspaceSelection | null, options?: Pick @@ -562,6 +595,7 @@ export const createLinearSlice: StateCreator = (s linearCustomViewDetailCache: {}, linearCustomViewIssueCache: {}, linearCustomViewProjectCache: {}, + linearListInvalidationToken: linearListInvalidationToken, checkLinearConnection: async (force = false) => { const contextKey = getProviderRuntimeContextKey(get().settings) @@ -994,9 +1028,10 @@ export const createLinearSlice: StateCreator = (s return get().linearSearchCache[cacheKey]?.data ?? null } const limit = clampLinearIssueListLimit(args.limit) + const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) const cacheKey = scopedLinearCacheKey( scope, - linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit) + linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit, attributeFilter) ) return get().linearListCache[cacheKey]?.data ?? null }, @@ -1026,9 +1061,16 @@ export const createLinearSlice: StateCreator = (s return } const limit = clampLinearIssueListLimit(args.limit) + const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) + const listArgs: LinearIssueListReadArgs = { + kind: 'list', + filter: args.filter, + limit, + attributeFilter + } const cacheKey = scopedLinearCacheKey( scope, - linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit) + linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit, attributeFilter) ) const inflight = inflightListRequests.get(cacheKey) if ( @@ -1040,7 +1082,7 @@ export const createLinearSlice: StateCreator = (s return } void get() - .listLinearIssues(args.filter, limit, options) + .listLinearIssues(listArgs, options) .catch(() => {}) }, @@ -1137,14 +1179,16 @@ export const createLinearSlice: StateCreator = (s return promise }, - listLinearIssues: async (filter = 'assigned', limit = 20, options) => { + listLinearIssues: async (args, options) => { const scope = getLinearReadScope(get().settings, options?.sourceContext) const { contextKey } = scope const workspaceId = getSelectedWorkspaceId(get().linearStatus) - const effectiveLimit = clampLinearIssueListLimit(limit) + const filter = args.filter ?? 'assigned' + const effectiveLimit = clampLinearIssueListLimit(args.limit) + const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) const cacheKey = scopedLinearCacheKey( scope, - linearListCacheKey(workspaceId, filter, effectiveLimit) + linearListCacheKey(workspaceId, filter, effectiveLimit, attributeFilter) ) const cached = get().linearListCache[cacheKey] if (!options?.force && isFresh(cached)) { @@ -1168,7 +1212,8 @@ export const createLinearSlice: StateCreator = (s scope.settings, filter, effectiveLimit, - workspaceId + workspaceId, + attributeFilter ) .then((result) => { const data = result as LinearCollectionResult @@ -2060,6 +2105,50 @@ export const createLinearSlice: StateCreator = (s return promise }, + invalidateLinearIssueLists: (options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const tokenScope = scope.cachePrefix ?? 'local' + const nextVersion = + linearListInvalidationToken.scope === tokenScope + ? (linearListInvalidationToken.version + 1) % LINEAR_LIST_INVALIDATION_VERSION_CAP || 1 + : 1 + linearListInvalidationToken = { scope: tokenScope, version: nextVersion } + + // Why: drop only attribute-filtered plain list entries in this source scope + // so the next TaskPage read is forced current without wiping search/unrelated + // collections. + set((s) => { + const nextListCache = { ...s.linearListCache } + let changed = false + for (const key of Object.keys(nextListCache)) { + const parts = key.split('::') + // Cache keys end with the attribute signature; unfiltered entries end empty. + const attributeSignature = parts.at(-1) ?? '' + if (!attributeSignature) { + continue + } + if (scope.cachePrefix) { + if (!key.startsWith(`${scope.cachePrefix}::`)) { + continue + } + } else if (parts[1] !== 'list') { + // Why: unscoped invalidation must not touch other runtimes' scoped list keys. + continue + } + delete nextListCache[key] + inflightListRequests.delete(key) + changed = true + } + if (!changed) { + return { linearListInvalidationToken: linearListInvalidationToken } + } + return { + linearListCache: nextListCache, + linearListInvalidationToken: linearListInvalidationToken + } + }) + }, + patchLinearIssue: (issueId, patch, options) => { const sourceScope = options?.sourceContext?.provider === 'linear' diff --git a/src/shared/linear-issue-attribute-filter.test.ts b/src/shared/linear-issue-attribute-filter.test.ts new file mode 100644 index 00000000000..57c0ecba441 --- /dev/null +++ b/src/shared/linear-issue-attribute-filter.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { + EMPTY_LINEAR_ISSUE_ATTRIBUTE_FILTER, + canonicalizeLinearIssueAttributeFilter, + emptyLinearIssueAttributeFilter, + isEmptyLinearIssueAttributeFilter, + linearIssueAttributeFilterSignature, + parseLinearIssueAttributeFilter, + type LinearIssueAttributeFilter +} from './linear-issue-attribute-filter' + +const sample: LinearIssueAttributeFilter = { + stateIds: ['b', 'a', 'a', ' c '], + priorities: [3, 1, 1, 0], + assignee: { kind: 'user', id: ' user-1 ' }, + labelIds: ['z', 'y', 'z'] +} + +describe('linear-issue-attribute-filter', () => { + it('treats the canonical empty value as empty and signature-less', () => { + expect(isEmptyLinearIssueAttributeFilter(EMPTY_LINEAR_ISSUE_ATTRIBUTE_FILTER)).toBe(true) + expect(isEmptyLinearIssueAttributeFilter(emptyLinearIssueAttributeFilter())).toBe(true) + expect(linearIssueAttributeFilterSignature(EMPTY_LINEAR_ISSUE_ATTRIBUTE_FILTER)).toBe('') + expect(linearIssueAttributeFilterSignature(undefined)).toBe('') + }) + + it('canonicalizes without mutating the input and produces a stable signature', () => { + const input = structuredClone(sample) + const canonical = canonicalizeLinearIssueAttributeFilter(input) + expect(input).toEqual(sample) + expect(canonical).toEqual({ + stateIds: ['a', 'b', 'c'], + priorities: [0, 1, 3], + assignee: { kind: 'user', id: 'user-1' }, + labelIds: ['y', 'z'] + }) + expect(linearIssueAttributeFilterSignature(sample)).toBe( + linearIssueAttributeFilterSignature(canonical) + ) + expect(linearIssueAttributeFilterSignature(sample)).toContain('"priorities":[0,1,3]') + }) + + it('changes the signature when any facet changes', () => { + const base = canonicalizeLinearIssueAttributeFilter(sample) + expect(linearIssueAttributeFilterSignature({ ...base, stateIds: ['x'] })).not.toBe( + linearIssueAttributeFilterSignature(base) + ) + expect(linearIssueAttributeFilterSignature({ ...base, priorities: [4] })).not.toBe( + linearIssueAttributeFilterSignature(base) + ) + expect( + linearIssueAttributeFilterSignature({ ...base, assignee: { kind: 'unassigned' } }) + ).not.toBe(linearIssueAttributeFilterSignature(base)) + expect(linearIssueAttributeFilterSignature({ ...base, labelIds: ['other'] })).not.toBe( + linearIssueAttributeFilterSignature(base) + ) + }) + + it('preserves priority 0 and unassigned assignee', () => { + const canonical = canonicalizeLinearIssueAttributeFilter({ + stateIds: [], + priorities: [0], + assignee: { kind: 'unassigned' }, + labelIds: [] + }) + expect(canonical.priorities).toEqual([0]) + expect(canonical.assignee).toEqual({ kind: 'unassigned' }) + expect(isEmptyLinearIssueAttributeFilter(canonical)).toBe(false) + }) + + it('parses valid wire input and rejects partial/unknown/malformed payloads', () => { + expect( + parseLinearIssueAttributeFilter({ + stateIds: ['s1'], + priorities: [0, 2], + assignee: { kind: 'unassigned' }, + labelIds: ['l1'] + }) + ).toEqual({ + stateIds: ['s1'], + priorities: [0, 2], + assignee: { kind: 'unassigned' }, + labelIds: ['l1'] + }) + + expect(() => parseLinearIssueAttributeFilter({ stateIds: [] })).toThrow(/required/) + expect(() => + parseLinearIssueAttributeFilter({ + stateIds: [], + priorities: [], + assignee: null, + labelIds: [], + extra: true + }) + ).toThrow(/unknown key/) + expect(() => + parseLinearIssueAttributeFilter({ + stateIds: [''], + priorities: [], + assignee: null, + labelIds: [] + }) + ).toThrow(/non-empty/) + expect(() => + parseLinearIssueAttributeFilter({ + stateIds: [], + priorities: [1.5], + assignee: null, + labelIds: [] + }) + ).toThrow(/integer/) + expect(() => + parseLinearIssueAttributeFilter({ + stateIds: [], + priorities: [5], + assignee: null, + labelIds: [] + }) + ).toThrow(/0 to 4/) + expect(() => + parseLinearIssueAttributeFilter({ + stateIds: [], + priorities: [], + assignee: { kind: 'user' }, + labelIds: [] + }) + ).toThrow(/assignee\.id/) + expect(() => + parseLinearIssueAttributeFilter({ + stateIds: Array.from({ length: 101 }, (_, i) => `s${i}`), + priorities: [], + assignee: null, + labelIds: [] + }) + ).toThrow(/exceeds 100/) + }) +}) diff --git a/src/shared/linear-issue-attribute-filter.ts b/src/shared/linear-issue-attribute-filter.ts new file mode 100644 index 00000000000..d856b4ef9cc --- /dev/null +++ b/src/shared/linear-issue-attribute-filter.ts @@ -0,0 +1,244 @@ +// Why: shared wire + cache identity for Linear plain-list attribute facets. +// IPC/RPC parse unknown input; renderer canonicalizes typed state. An empty +// filter is omitted from transport so unfiltered list requests stay equivalent. + +export const LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH = 256 +export const LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS = 100 +export const LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS = 100 +export const LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES = 5 + +export type LinearIssueAttributeAssignee = { kind: 'user'; id: string } | { kind: 'unassigned' } + +export type LinearIssueAttributeFilter = { + stateIds: string[] + priorities: number[] + assignee: LinearIssueAttributeAssignee | null + labelIds: string[] +} + +export const EMPTY_LINEAR_ISSUE_ATTRIBUTE_FILTER: LinearIssueAttributeFilter = Object.freeze({ + stateIds: Object.freeze([]) as unknown as string[], + priorities: Object.freeze([]) as unknown as number[], + assignee: null, + labelIds: Object.freeze([]) as unknown as string[] +}) as LinearIssueAttributeFilter + +const ATTRIBUTE_FILTER_KEYS = new Set(['stateIds', 'priorities', 'assignee', 'labelIds']) + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function canonicalizeIds(ids: string[]): string[] { + const seen = new Set() + const next: string[] = [] + for (const raw of ids) { + const id = raw.trim() + if (!id || seen.has(id)) { + continue + } + seen.add(id) + next.push(id) + } + next.sort(compareStrings) + return next +} + +function canonicalizePriorities(priorities: number[]): number[] { + const seen = new Set() + const next: number[] = [] + for (const priority of priorities) { + if (!Number.isInteger(priority) || priority < 0 || priority > 4 || seen.has(priority)) { + continue + } + seen.add(priority) + next.push(priority) + } + next.sort((a, b) => a - b) + return next +} + +function canonicalizeAssignee( + assignee: LinearIssueAttributeAssignee | null +): LinearIssueAttributeAssignee | null { + if (assignee === null) { + return null + } + if (assignee.kind === 'unassigned') { + return { kind: 'unassigned' } + } + const id = assignee.id.trim() + if (!id) { + return null + } + return { kind: 'user', id } +} + +export function emptyLinearIssueAttributeFilter(): LinearIssueAttributeFilter { + return { + stateIds: [], + priorities: [], + assignee: null, + labelIds: [] + } +} + +export function canonicalizeLinearIssueAttributeFilter( + filter: LinearIssueAttributeFilter +): LinearIssueAttributeFilter { + return { + stateIds: canonicalizeIds(filter.stateIds), + priorities: canonicalizePriorities(filter.priorities), + assignee: canonicalizeAssignee(filter.assignee), + labelIds: canonicalizeIds(filter.labelIds) + } +} + +export function isEmptyLinearIssueAttributeFilter( + filter: LinearIssueAttributeFilter | null | undefined +): boolean { + if (!filter) { + return true + } + const canonical = canonicalizeLinearIssueAttributeFilter(filter) + return ( + canonical.stateIds.length === 0 && + canonical.priorities.length === 0 && + canonical.assignee === null && + canonical.labelIds.length === 0 + ) +} + +export function linearIssueAttributeFilterSignature( + filter: LinearIssueAttributeFilter | null | undefined +): string { + if (!filter || isEmptyLinearIssueAttributeFilter(filter)) { + return '' + } + const canonical = canonicalizeLinearIssueAttributeFilter(filter) + return JSON.stringify({ + stateIds: canonical.stateIds, + priorities: canonical.priorities, + assignee: canonical.assignee, + labelIds: canonical.labelIds + }) +} + +function assertId(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new Error(`Invalid Linear attribute filter: ${field} must be a string id`) + } + const id = value.trim() + if (!id) { + throw new Error(`Invalid Linear attribute filter: ${field} must be non-empty`) + } + if (id.length > LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH) { + throw new Error( + `Invalid Linear attribute filter: ${field} exceeds ${LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH} characters` + ) + } + return id +} + +function assertIdArray(value: unknown, field: string, max: number): string[] { + if (!Array.isArray(value)) { + throw new Error(`Invalid Linear attribute filter: ${field} must be an array`) + } + if (value.length > max) { + throw new Error(`Invalid Linear attribute filter: ${field} exceeds ${max} entries`) + } + return value.map((entry, index) => assertId(entry, `${field}[${index}]`)) +} + +function assertPriorities(value: unknown): number[] { + if (!Array.isArray(value)) { + throw new Error('Invalid Linear attribute filter: priorities must be an array') + } + if (value.length > LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES) { + throw new Error( + `Invalid Linear attribute filter: priorities exceeds ${LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES} entries` + ) + } + return value.map((entry, index) => { + if (typeof entry !== 'number' || !Number.isInteger(entry) || entry < 0 || entry > 4) { + throw new Error( + `Invalid Linear attribute filter: priorities[${index}] must be an integer from 0 to 4` + ) + } + return entry + }) +} + +function assertAssignee(value: unknown): LinearIssueAttributeAssignee | null { + if (value === null) { + return null + } + if (!isPlainObject(value)) { + throw new Error('Invalid Linear attribute filter: assignee must be an object or null') + } + const keys = Object.keys(value) + if (value.kind === 'unassigned') { + if (keys.some((key) => key !== 'kind')) { + throw new Error('Invalid Linear attribute filter: unassigned assignee has unknown keys') + } + return { kind: 'unassigned' } + } + if (value.kind === 'user') { + if (keys.some((key) => key !== 'kind' && key !== 'id')) { + throw new Error('Invalid Linear attribute filter: user assignee has unknown keys') + } + return { kind: 'user', id: assertId(value.id, 'assignee.id') } + } + throw new Error('Invalid Linear attribute filter: assignee.kind must be "user" or "unassigned"') +} + +/** Throwing parser for IPC/RPC wire input. Present partial objects are invalid. */ +export function parseLinearIssueAttributeFilter(value: unknown): LinearIssueAttributeFilter { + if (value === undefined || value === null) { + throw new Error('Invalid Linear attribute filter: value is required when present') + } + if (!isPlainObject(value)) { + throw new Error('Invalid Linear attribute filter: expected an object') + } + for (const key of Object.keys(value)) { + if (!ATTRIBUTE_FILTER_KEYS.has(key)) { + throw new Error(`Invalid Linear attribute filter: unknown key "${key}"`) + } + } + if ( + !('stateIds' in value) || + !('priorities' in value) || + !('assignee' in value) || + !('labelIds' in value) + ) { + throw new Error( + 'Invalid Linear attribute filter: stateIds, priorities, assignee, and labelIds are required' + ) + } + + const parsed: LinearIssueAttributeFilter = { + stateIds: assertIdArray( + value.stateIds, + 'stateIds', + LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS + ), + priorities: assertPriorities(value.priorities), + assignee: assertAssignee(value.assignee), + labelIds: assertIdArray(value.labelIds, 'labelIds', LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS) + } + return canonicalizeLinearIssueAttributeFilter(parsed) +} + +export function optionalParsedLinearIssueAttributeFilter( + value: unknown +): LinearIssueAttributeFilter | undefined { + if (value === undefined) { + return undefined + } + const parsed = parseLinearIssueAttributeFilter(value) + return isEmptyLinearIssueAttributeFilter(parsed) ? undefined : parsed +} From 0f1bdb9244e534855ca70117a681c1a46d85a09e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:28:56 -0700 Subject: [PATCH 10/17] Diff view flickering while scrolling (#8026) * Fix diff viewer scroll flicker by marking programmatic scrolls explicitl - Wall-clock "recent direct input" heuristics misclassified scroll events during main-thread jank, causing stale anchor restores to fight active wheel scrolling and produce visible flicker. - Replace timing-based classification with explicit programmatic scroll marks: every self-initiated scroll write (virtualizer corrections, anchor restores, scrollToIndex) is marked so listeners can distinguish it from genuine user input, including clamped landings from shrunken scroll ranges. - Gate anchor restore on a structural restoreSignal (row add/remove/ collapse, remount, layout flips) instead of every measurement tick, so restores only run when something actually changed. - Extract restore/listener logic into dedicated modules (virtualized-scroll-anchor-listener.ts, virtualized-scroll-anchor-restore.ts, programmatic-scroll-marks.ts) for testability. - Leave WorktreeList on the legacy wall-clock suppression for now, with a TODO to migrate once the new approach is validated for the sidebar. * Fix diff viewer scroll flicker from stale marks and dropped restores - Skip marking no-op writes since they emit no scroll event and could claim a later user scroll within the mark-matching epsilon - Distinguish browser clamp (viewport can't reach target) from real user takeover using in-range origin and direct-input checks - Sync scrollOffsetRef/anchor bookkeeping on our own programmatic writes so divergence checks don't misread them as user scrolling - Arm pending restore before skip guards so a restore owed during active input retries once input settles instead of being dropped - Guard the rAF-deferred restore against a user scroll that disarmed it between scheduling and execution * Fix leaked pending-restore flag when scroll anchor is unresolvable Clearing pendingRestoreRef on both early-return paths prevents a stale armed restore from firing on a later measurement tick with an unchanged restoreSignal, which could re-trigger scroll writes and flicker. * Add screenshot of current diff view scroll flicker state - Captures the visual bug before the fix, for reference in the PR * rm tem file --- .../components/editor/CombinedDiffViewer.tsx | 76 ++- .../src/components/sidebar/WorktreeList.tsx | 3 + .../hooks/programmatic-scroll-marks.test.ts | 75 +++ .../src/hooks/programmatic-scroll-marks.ts | 72 +++ ...tualizedScrollAnchor.listener-deps.test.ts | 7 +- ...dScrollAnchor.marks-restore-signal.test.ts | 432 ++++++++++++++++++ .../src/hooks/useVirtualizedScrollAnchor.ts | 223 ++++----- .../virtualized-scroll-anchor-listener.ts | 140 ++++++ .../virtualized-scroll-anchor-recording.ts | 6 +- .../virtualized-scroll-anchor-restore.ts | 148 ++++++ 10 files changed, 1040 insertions(+), 142 deletions(-) create mode 100644 src/renderer/src/hooks/programmatic-scroll-marks.test.ts create mode 100644 src/renderer/src/hooks/programmatic-scroll-marks.ts create mode 100644 src/renderer/src/hooks/useVirtualizedScrollAnchor.marks-restore-signal.test.ts create mode 100644 src/renderer/src/hooks/virtualized-scroll-anchor-listener.ts create mode 100644 src/renderer/src/hooks/virtualized-scroll-anchor-restore.ts diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 61dc1ffec0e..a206fa8c8f9 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -4,15 +4,17 @@ restore-on-remount caching, and scroll preservation. Splitting those pieces across smaller files would make the lifecycle edges harder to reason about and more error-prone than keeping the whole viewer flow together. */ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: diff entry changes must reset virtualizer measurement and generation state in lockstep with external scroll restoration. */ -import React, { useState, useEffect, useCallback, useRef, useLayoutEffect } from 'react' -import { useVirtualizer } from '@tanstack/react-virtual' +import React, { useState, useEffect, useCallback, useRef, useLayoutEffect, useMemo } from 'react' +import { elementScroll, useVirtualizer } from '@tanstack/react-virtual' import type { editor as monacoEditor } from 'monaco-editor' import { useAppStore } from '@/store' import { useVirtualizedScrollAnchor, + VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, type VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' import { getVirtualizedScrollAnchorForOffset } from '@/hooks/virtualized-scroll-anchor-recording' +import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' import { joinPath } from '@/lib/path' import { detectLanguage } from '@/lib/language-detect' import { setWithLRU } from '@/lib/scroll-cache' @@ -293,6 +295,11 @@ export default function CombinedDiffViewer({ combinedDiffScrollAnchorCache.get(viewStateKey) ?? null ) const directScrollInputUntilRef = useRef(0) + const [programmaticScrollMarks] = useState(createProgrammaticScrollMarks) + // Why: a scroll event pinned at a shrunken max is a browser clamp, not user + // input; bumping this asks the anchor restore to re-pin the viewport. + const [clampRestoreCount, setClampRestoreCount] = useState(0) + const lastScrollHeightRef = useRef(0) const activeScrollbarDragCleanupRef = useRef(null) const loadedIndicesRef = useRef>(new Set()) const loadingIndicesRef = useRef>(new Set()) @@ -840,6 +847,18 @@ export default function CombinedDiffViewer({ }, overscan: COMBINED_DIFF_OVERSCAN, initialOffset: () => scrollOffsetRef.current, + // Why: every scroll the virtualizer issues (scrollToIndex, measurement + // corrections) must be marked so scroll events can be attributed to the + // user only when this code didn't cause them. + scrollToFn: (offset, options, instance) => { + const target = offset + (options.adjustments ?? 0) + // Why: a write to the current position emits no scroll event; marking + // it would leave a stale mark that can claim a later user scroll. + if (instance.scrollElement?.scrollTop !== target) { + programmaticScrollMarks.mark(target) + } + elementScroll(offset, options, instance) + }, getItemKey: (index) => { const section = sections[index] if (!section) { @@ -906,7 +925,8 @@ export default function CombinedDiffViewer({ offset: Math.min( firstVisible.rect.height, Math.max(0, containerRect.top - firstVisible.rect.top) - ) + ), + scrollTop: container.scrollTop } scrollAnchorRef.current = anchor latestDomScrollAnchorRef.current = anchor @@ -930,14 +950,29 @@ export default function CombinedDiffViewer({ [recordCombinedDiffDomScrollAnchor, writeCombinedDiffScrollAnchor] ) + // Why: restore only on structural changes (rows added/removed/collapsed, + // remount, layout-mode flips, clamp recovery). Measurement churn during + // scrolling is compensated by the virtualizer itself; restoring on it wrote + // scrollTop against active wheel input whenever main-thread jank outlived + // the direct-input window. + const combinedDiffRestoreSignal = useMemo( + () => + `${generation}|${sideBySide ? 'sbs' : 'inline'}|${clampRestoreCount}|${sections + .map((section) => `${section.key}:${section.collapsed ? 'c' : 'e'}`) + .join(',')}`, + [clampRestoreCount, generation, sections, sideBySide] + ) + useVirtualizedScrollAnchor({ anchorRef: scrollAnchorRef, getItemElementKey: getCombinedDiffSectionElementKey, getRowKey: getCombinedDiffSectionKey, hasDirectScrollInput, itemElementSelector: '[data-combined-diff-section-row]', + programmaticScrollMarks, recordAnchorOnCleanup: false, recordAnchorOnScroll: false, + restoreSignal: combinedDiffRestoreSignal, rows: sections, scrollElementRef: scrollContainerRef, shouldSkipRestore: hasDirectScrollInput, @@ -1001,6 +1036,13 @@ export default function CombinedDiffViewer({ scrollAnchorRef.current = null latestDomScrollAnchorRef.current = null virtualizer.scrollToIndex(index, { align: 'start' }) + // Why: this jump is marked programmatic, so no scroll event records + // an anchor for it; snapshot the destination once layout settles. + window.requestAnimationFrame(() => { + scrollContainerRef.current?.dispatchEvent( + new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT) + ) + }) } }) if (navigatedIndex !== null) { @@ -1336,16 +1378,35 @@ export default function CombinedDiffViewer({ scrollTop }) } - const handleScroll = (): void => { - if (!hasDirectScrollInput()) { + lastScrollHeightRef.current = container.scrollHeight + const handleScroll = (event: Event): void => { + const scrollTop = container.scrollTop + const scrollHeight = container.scrollHeight + const maxScrollTop = Math.max(0, scrollHeight - container.clientHeight) + const shrank = scrollHeight < lastScrollHeightRef.current - 1 + lastScrollHeightRef.current = scrollHeight + if (programmaticScrollMarks.consume(event, scrollTop, maxScrollTop)) { updateCombinedDiffScrollbar() return } - recordCombinedDiffVirtualScrollAnchor(container.scrollTop) + if (shrank && scrollTop >= maxScrollTop - 1 && scrollOffsetRef.current > maxScrollTop + 1) { + // Why: pinned at a max that just shrank, coming from an offset the new + // range can't reach, is the browser clamping the viewport, not user + // input (a user scroll to the bottom starts from an in-range offset); + // ask the anchor restore to re-pin instead of recording the clamped + // position as intentional. + setClampRestoreCount((count) => count + 1) + updateCombinedDiffScrollbar() + return + } + // Why: any unmarked scroll is the user's (wheel, momentum, keyboard, + // scrollbar drag) — including events whose input never reached the + // wheel handler because the main thread was janked past its window. + recordCombinedDiffVirtualScrollAnchor(scrollTop) updateCachedScrollPosition({ recordDomAnchor: false, scheduleSettled: true, - scrollTop: container.scrollTop, + scrollTop, writeAnchor: true }) } @@ -1376,6 +1437,7 @@ export default function CombinedDiffViewer({ entrySignature, hasDirectScrollInput, persistCombinedDiffScrollAnchor, + programmaticScrollMarks, recordCombinedDiffVirtualScrollAnchor, sections.length, updateCombinedDiffScrollbar, diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 34725365a37..a1542323e62 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -1998,6 +1998,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp // TanStack's default correction writes scrollTop in that path, which feels // like rubber-banding. Structural mutations still use our explicit anchor // restore after direct scroll input has settled. + // TODO(scroll-origin-migration): this wall-clock suppression misclassifies + // under main-thread jank; migrate to programmaticScrollMarks + restoreSignal + // (see CombinedDiffViewer) once that wiring is validated for the sidebar. virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => shouldAdjustWorktreeSidebarMeasuredRowScroll({ isScrolling: instance.isScrolling, diff --git a/src/renderer/src/hooks/programmatic-scroll-marks.test.ts b/src/renderer/src/hooks/programmatic-scroll-marks.test.ts new file mode 100644 index 00000000000..9b386b4f4b6 --- /dev/null +++ b/src/renderer/src/hooks/programmatic-scroll-marks.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { createProgrammaticScrollMarks } from './programmatic-scroll-marks' + +const scrollEvent = (): Event => new Event('scroll') + +describe('createProgrammaticScrollMarks', () => { + it('classifies an event matching a marked target as programmatic', () => { + const marks = createProgrammaticScrollMarks() + marks.mark(500) + expect(marks.consume(scrollEvent(), 500, 10_000)).toBe(true) + }) + + it('matches within the epsilon for fractional scroll positions', () => { + const marks = createProgrammaticScrollMarks() + marks.mark(500) + expect(marks.consume(scrollEvent(), 501.6, 10_000)).toBe(true) + }) + + it('classifies an unmarked scroll as user input', () => { + const marks = createProgrammaticScrollMarks() + marks.mark(500) + expect(marks.consume(scrollEvent(), 660, 10_000)).toBe(false) + }) + + it('treats a clamped landing of an out-of-range write as programmatic', () => { + const marks = createProgrammaticScrollMarks() + marks.mark(12_000) + expect(marks.consume(scrollEvent(), 8_000, 8_000)).toBe(true) + }) + + it('does not treat a user scroll to the bottom as a clamped write', () => { + const marks = createProgrammaticScrollMarks() + marks.mark(4_000) + expect(marks.consume(scrollEvent(), 8_000, 8_000)).toBe(false) + }) + + it('returns the same classification for the same event across listeners', () => { + const marks = createProgrammaticScrollMarks() + marks.mark(500) + const event = scrollEvent() + expect(marks.consume(event, 500, 10_000)).toBe(true) + // Second listener on the same event: mark already consumed, cache must answer. + expect(marks.consume(event, 500, 10_000)).toBe(true) + // A fresh event at the same offset is a new scroll and no mark remains. + expect(marks.consume(scrollEvent(), 500, 10_000)).toBe(false) + }) + + it('drops older marks whose events were coalesced away', () => { + const marks = createProgrammaticScrollMarks() + marks.mark(300) + marks.mark(700) + expect(marks.consume(scrollEvent(), 700, 10_000)).toBe(true) + // The 300 mark predates the matched write; it must not claim a later user scroll. + expect(marks.consume(scrollEvent(), 300, 10_000)).toBe(false) + }) + + it('consumes marks independently when events arrive in write order', () => { + const marks = createProgrammaticScrollMarks() + marks.mark(300) + marks.mark(700) + expect(marks.consume(scrollEvent(), 300, 10_000)).toBe(true) + expect(marks.consume(scrollEvent(), 700, 10_000)).toBe(true) + expect(marks.consume(scrollEvent(), 700, 10_000)).toBe(false) + }) + + it('bounds the pending queue', () => { + const marks = createProgrammaticScrollMarks() + for (let i = 0; i < 40; i++) { + marks.mark(i * 1_000) + } + // Oldest marks were evicted; only the most recent 16 remain matchable. + expect(marks.consume(scrollEvent(), 0, 100_000)).toBe(false) + expect(marks.consume(scrollEvent(), 39_000, 100_000)).toBe(true) + }) +}) diff --git a/src/renderer/src/hooks/programmatic-scroll-marks.ts b/src/renderer/src/hooks/programmatic-scroll-marks.ts new file mode 100644 index 00000000000..b8aed0aa0e3 --- /dev/null +++ b/src/renderer/src/hooks/programmatic-scroll-marks.ts @@ -0,0 +1,72 @@ +const SCROLL_MARK_MATCH_EPSILON = 2 +// Why: a bounded queue is the staleness limit. Wall-clock expiry would +// reintroduce the timing assumptions this module exists to remove. Known +// accepted window: a marked no-op write emits no scroll event, so its target +// can linger and claim a later user scroll within the epsilon; the bound and +// splice-on-match keep that window narrow. +const MAX_PENDING_SCROLL_MARKS = 16 + +export type ProgrammaticScrollMarks = { + /** Register the target offset of a scroll write this code is about to make. */ + mark: (targetOffset: number) => void + /** + * Classify a scroll event: true when it matches a registered write (or its + * browser-clamped landing spot). Idempotent per Event so multiple listeners + * on the same element agree on the classification. + */ + consume: (event: Event, scrollOffset: number, maxScrollOffset: number) => boolean +} + +/** + * Distinguishes self-initiated scrolls from user scrolls by explicit marks + * instead of wall-clock input windows. The system always knows when it writes + * scrollTop; it can never reliably know when the user scrolled — under + * main-thread jank, input events dispatch late and time-window heuristics + * misclassify exactly when it matters. + */ +export function createProgrammaticScrollMarks(): ProgrammaticScrollMarks { + const pendingTargets: number[] = [] + const classifiedEvents = new WeakMap() + + const matchesTarget = ( + targetOffset: number, + scrollOffset: number, + maxScrollOffset: number + ): boolean => { + if (Math.abs(scrollOffset - targetOffset) <= SCROLL_MARK_MATCH_EPSILON) { + return true + } + // Why: a write past the scrollable range lands at the clamped max, not at + // its target; that landing is still our scroll, not the user's. + return ( + targetOffset > maxScrollOffset + SCROLL_MARK_MATCH_EPSILON && + scrollOffset >= maxScrollOffset - SCROLL_MARK_MATCH_EPSILON + ) + } + + return { + mark: (targetOffset: number): void => { + pendingTargets.push(targetOffset) + if (pendingTargets.length > MAX_PENDING_SCROLL_MARKS) { + pendingTargets.shift() + } + }, + consume: (event: Event, scrollOffset: number, maxScrollOffset: number): boolean => { + const cached = classifiedEvents.get(event) + if (cached !== undefined) { + return cached + } + const matchedIndex = pendingTargets.findIndex((targetOffset) => + matchesTarget(targetOffset, scrollOffset, maxScrollOffset) + ) + if (matchedIndex !== -1) { + // Why: scroll events arrive in write order; older marks whose events + // were coalesced away must not linger to claim a later user scroll. + pendingTargets.splice(0, matchedIndex + 1) + } + const isProgrammatic = matchedIndex !== -1 + classifiedEvents.set(event, isProgrammatic) + return isProgrammatic + } + } +} diff --git a/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts b/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts index a30b3f97ce5..bb8755760ac 100644 --- a/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts +++ b/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts @@ -69,7 +69,8 @@ describe('useVirtualizedScrollAnchor listener effect dependencies', () => { // Why: cleanup records the current anchor. If rows are dependencies, a // delete reruns cleanup after mutation and overwrites the pre-delete anchor. - expect(initialDeps).toEqual([scrollElementRef, scrollOffsetRef]) + // Only stable refs are allowed here. + expect(initialDeps).toEqual([anchorRef, scrollElementRef, scrollOffsetRef]) expect(nextDeps).toEqual(initialDeps) }) @@ -113,7 +114,9 @@ describe('useVirtualizedScrollAnchor listener effect dependencies', () => { harness.effects[1]?.effect() expect(scrollElementRef.current.scrollTop).toBe(4116) - expect(anchorRef.current).toEqual({ key: 'row-1', offset: 3358 }) + // Why: the anchor identity is preserved through the transitional restore; + // only its source scrollTop is refreshed to the restored offset. + expect(anchorRef.current).toEqual({ key: 'row-1', offset: 3358, scrollTop: 4116 }) }) it('can ignore generic scroll anchor recording while preserving the saved anchor', async () => { diff --git a/src/renderer/src/hooks/useVirtualizedScrollAnchor.marks-restore-signal.test.ts b/src/renderer/src/hooks/useVirtualizedScrollAnchor.marks-restore-signal.test.ts new file mode 100644 index 00000000000..0e7ec50cf95 --- /dev/null +++ b/src/renderer/src/hooks/useVirtualizedScrollAnchor.marks-restore-signal.test.ts @@ -0,0 +1,432 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createProgrammaticScrollMarks } from './programmatic-scroll-marks' + +function createReactHookHarness() { + const refs: { current: unknown }[] = [] + const effects: { + deps: readonly unknown[] | undefined + effect: () => void | (() => void) + }[] = [] + let refIndex = 0 + + return { + beginRender: () => { + refIndex = 0 + effects.length = 0 + }, + effects, + react: { + useCallback: unknown>(callback: T): T => callback, + useLayoutEffect: (effect: () => void | (() => void), deps?: readonly unknown[]) => { + effects.push({ deps, effect }) + }, + useMemo: (factory: () => T): T => factory(), + useRef: (initialValue: T): { current: T } => { + const index = refIndex + refIndex += 1 + refs[index] ??= { current: initialValue } + return refs[index] as { current: T } + } + } + } +} + +type FakeRowElement = { + getBoundingClientRect: () => { bottom: number; height: number; top: number } + isConnected: boolean + key: string +} + +function createScrollElement({ + clientHeight = 880, + rowElements = [] as FakeRowElement[], + scrollHeight = 30_000, + scrollTop = 0 +}) { + const scrollHandlers: ((event: Event) => void)[] = [] + const el = { + addEventListener: vi.fn((eventName: string, handler: (event: Event) => void) => { + if (eventName === 'scroll') { + scrollHandlers.push(handler) + } + }), + clientHeight, + getBoundingClientRect: () => ({ bottom: clientHeight, top: 0 }), + querySelectorAll: vi.fn(() => rowElements), + removeEventListener: vi.fn(), + scrollHeight, + scrollTop + } + return { + el, + emitScroll: (top: number): void => { + el.scrollTop = top + const event = new Event('scroll') + scrollHandlers.forEach((handler) => handler(event)) + } + } +} + +const anchoredRowElement = (key: string, top: number): FakeRowElement => ({ + getBoundingClientRect: () => ({ bottom: top + 4_000, height: 4_000, top }), + isConnected: true, + key +}) + +describe('useVirtualizedScrollAnchor with marks + restoreSignal', () => { + afterEach(() => { + vi.doUnmock('react') + vi.resetModules() + }) + + const loadHook = async () => { + const harness = createReactHookHarness() + vi.doMock('react', () => harness.react) + const { useVirtualizedScrollAnchor } = await import('./useVirtualizedScrollAnchor') + return { harness, useVirtualizedScrollAnchor } + } + + const virtualizerWithRow1 = () => ({ + getVirtualItems: () => [ + { index: 0, start: 0, end: 758 }, + { index: 1, start: 758, end: 4_512 } + ], + isScrolling: false, + scrollToIndex: vi.fn() + }) + + it('does not re-attempt restore on measurement churn when the signal is unchanged', async () => { + const { harness, useVirtualizedScrollAnchor } = await loadHook() + // Row-1 already sits exactly at the anchored offset, so the first run confirms. + const { el } = createScrollElement({ + rowElements: [anchoredRowElement('row-1', -3_358)], + scrollTop: 746 + }) + const anchorRef = { current: { key: 'row-1', offset: 3_358, scrollTop: 746 } } + const virtualizer = virtualizerWithRow1() + + const render = (restoreSignal: string, totalSize: number) => { + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getItemElementKey: (element: FakeRowElement) => element.key, + getRowKey: (row: string) => row, + itemElementSelector: '[data-row]', + programmaticScrollMarks: createProgrammaticScrollMarks(), + recordAnchorOnScroll: false, + restoreSignal, + rows: ['row-0', 'row-1'], + scrollElementRef: { current: el }, + scrollOffsetRef: { current: 746 }, + totalSize, + virtualizer + } as never) + harness.effects[1]?.effect() + } + + render('signal-a', 30_000) + expect(el.querySelectorAll).toHaveBeenCalled() + const attemptsAfterConfirm = el.querySelectorAll.mock.calls.length + + // Measurement churn: totalSize changed, structural signal did not. + render('signal-a', 31_500) + expect(el.querySelectorAll.mock.calls.length).toBe(attemptsAfterConfirm) + expect(virtualizer.scrollToIndex).not.toHaveBeenCalled() + }) + + it('lets the user position win when the viewport moved after the anchor was recorded', async () => { + const { harness, useVirtualizedScrollAnchor } = await loadHook() + const { el } = createScrollElement({ + rowElements: [anchoredRowElement('row-1', -3_358)], + // The user (or compositor) scrolled beyond where the anchor was recorded. + scrollTop: 906 + }) + const anchorRef = { current: { key: 'row-1', offset: 3_358, scrollTop: 746 } } + const virtualizer = virtualizerWithRow1() + + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getItemElementKey: (element: FakeRowElement) => element.key, + getRowKey: (row: string) => row, + itemElementSelector: '[data-row]', + programmaticScrollMarks: createProgrammaticScrollMarks(), + recordAnchorOnScroll: false, + restoreSignal: 'signal-a', + rows: ['row-0', 'row-1'], + scrollElementRef: { current: el }, + scrollOffsetRef: { current: 746 }, + totalSize: 30_000, + virtualizer + } as never) + harness.effects[1]?.effect() + + expect(el.scrollTop).toBe(906) + expect(el.querySelectorAll).not.toHaveBeenCalled() + expect(virtualizer.scrollToIndex).not.toHaveBeenCalled() + }) + + it('still restores when a browser clamp explains the divergence', async () => { + const { harness, useVirtualizedScrollAnchor } = await loadHook() + // Content above shrank: the anchor points past the new max and the browser + // clamped the viewport to the bottom. + const { el } = createScrollElement({ + rowElements: [], + scrollHeight: 2_000, + scrollTop: 1_120 + }) + const anchorRef = { current: { key: 'row-1', offset: 3_358, scrollTop: 5_000 } } + const virtualizer = virtualizerWithRow1() + + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getItemElementKey: (element: FakeRowElement) => element.key, + getRowKey: (row: string) => row, + itemElementSelector: '[data-row]', + programmaticScrollMarks: createProgrammaticScrollMarks(), + recordAnchorOnScroll: false, + restoreSignal: 'signal-a', + rows: ['row-0', 'row-1'], + scrollElementRef: { current: el }, + scrollOffsetRef: { current: 5_000 }, + totalSize: 2_000, + virtualizer + } as never) + harness.effects[1]?.effect() + + expect(el.querySelectorAll).toHaveBeenCalled() + }) + + it('yields the mount restore to an unmarked user scroll instead of rewriting it', async () => { + const { harness, useVirtualizedScrollAnchor } = await loadHook() + const { el, emitScroll } = createScrollElement({ scrollTop: 0 }) + const anchorRef = { current: null } + + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getRowKey: (row: string) => row, + programmaticScrollMarks: createProgrammaticScrollMarks(), + recordAnchorOnScroll: false, + restoreSignal: 'signal-a', + rows: ['row-0', 'row-1'], + scrollElementRef: { current: el }, + scrollOffsetRef: { current: 4_000 }, + totalSize: 30_000, + virtualizer: virtualizerWithRow1() + } as never) + harness.effects[0]?.effect() + // The mount write landed and was marked. + expect(el.scrollTop).toBe(4_000) + + // An unmarked scroll means the user took over — even without any wheel + // event reaching a direct-input tracker (the jank case). + emitScroll(4_200) + expect(el.scrollTop).toBe(4_200) + }) + + it('keeps structural restores alive after its own marked writes move the viewport', async () => { + const { harness, useVirtualizedScrollAnchor } = await loadHook() + const marks = createProgrammaticScrollMarks() + const rowElement = anchoredRowElement('row-1', -3_358) + const { el, emitScroll } = createScrollElement({ + rowElements: [rowElement], + scrollTop: 746 + }) + const anchorRef = { current: { key: 'row-1', offset: 3_358, scrollTop: 746 } } + const scrollOffsetRef = { current: 746 } + const virtualizer = virtualizerWithRow1() + + const render = (restoreSignal: string) => { + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getItemElementKey: (element: FakeRowElement) => element.key, + getRowKey: (row: string) => row, + itemElementSelector: '[data-row]', + programmaticScrollMarks: marks, + recordAnchorOnScroll: false, + restoreSignal, + rows: ['row-0', 'row-1'], + scrollElementRef: { current: el }, + scrollOffsetRef, + totalSize: 30_000, + virtualizer + } as never) + harness.effects[0]?.effect() + harness.effects[1]?.effect() + } + + render('signal-a') + // A virtualizer size-adjustment correction (marked) moves the viewport. + marks.mark(946) + emitScroll(946) + // Bookkeeping followed the marked write, so the divergence gate must not + // read it as user input. + expect(anchorRef.current.scrollTop).toBe(946) + expect(scrollOffsetRef.current).toBe(946) + + // A structural change now still restores instead of being dropped. + const attemptsBefore = el.querySelectorAll.mock.calls.length + render('signal-b') + expect(el.querySelectorAll.mock.calls.length).toBeGreaterThan(attemptsBefore) + }) + + it('retries a signal-change restore that was skipped during direct input', async () => { + const { harness, useVirtualizedScrollAnchor } = await loadHook() + const { el } = createScrollElement({ + rowElements: [anchoredRowElement('row-1', -3_358)], + scrollTop: 746 + }) + const anchorRef = { current: { key: 'row-1', offset: 3_358, scrollTop: 746 } } + const virtualizer = virtualizerWithRow1() + let directInput = true + + const render = (restoreSignal: string, totalSize: number) => { + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getItemElementKey: (element: FakeRowElement) => element.key, + getRowKey: (row: string) => row, + itemElementSelector: '[data-row]', + programmaticScrollMarks: createProgrammaticScrollMarks(), + recordAnchorOnScroll: false, + restoreSignal, + rows: ['row-0', 'row-1'], + scrollElementRef: { current: el }, + scrollOffsetRef: { current: 746 }, + shouldSkipRestore: () => directInput, + totalSize, + virtualizer + } as never) + harness.effects[1]?.effect() + } + + // Signal changes while wheel input is active: restore is skipped. + render('signal-a', 30_000) + expect(el.querySelectorAll).not.toHaveBeenCalled() + + // Input settled; a later measurement tick must still run the owed restore + // even though the signal did not change again. + directInput = false + render('signal-a', 31_000) + expect(el.querySelectorAll).toHaveBeenCalled() + }) + + it('does not stay armed when the anchor row and all fallbacks are gone', async () => { + const { harness, useVirtualizedScrollAnchor } = await loadHook() + const { el } = createScrollElement({ + rowElements: [anchoredRowElement('row-1', -3_358)], + scrollTop: 746 + }) + const anchorRef = { current: { key: 'row-gone', offset: 3_358, scrollTop: 746 } } + const virtualizer = virtualizerWithRow1() + + const render = (rows: readonly string[], totalSize: number) => { + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getItemElementKey: (element: FakeRowElement) => element.key, + getRowKey: (row: string) => row, + itemElementSelector: '[data-row]', + programmaticScrollMarks: createProgrammaticScrollMarks(), + recordAnchorOnScroll: false, + restoreSignal: 'signal-a', + rows, + scrollElementRef: { current: el }, + scrollOffsetRef: { current: 746 }, + totalSize, + virtualizer + } as never) + harness.effects[1]?.effect() + } + + // Signal change arms a restore, but the anchor resolves to no row. + render(['row-0', 'row-2'], 30_000) + expect(el.querySelectorAll).not.toHaveBeenCalled() + + // The arm must not leak: a later measurement tick with an unchanged + // signal stays out of restore even though the anchor is now resolvable + // (in production, resolvability changes always change the signal too). + render(['row-0', 'row-gone'], 31_000) + expect(el.querySelectorAll).not.toHaveBeenCalled() + expect(virtualizer.scrollToIndex).not.toHaveBeenCalled() + }) + + it('does not treat a browser clamp during the mount restore as user takeover', async () => { + const { harness, useVirtualizedScrollAnchor } = await loadHook() + const marks = createProgrammaticScrollMarks() + const { el, emitScroll } = createScrollElement({ scrollHeight: 3_000, scrollTop: 0 }) + const anchorRef = { current: null } + const scrollOffsetRef = { current: 4_000 } + + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getRowKey: (row: string) => row, + programmaticScrollMarks: marks, + recordAnchorOnScroll: false, + restoreSignal: 'signal-a', + rows: ['row-0', 'row-1'], + scrollElementRef: { current: el }, + scrollOffsetRef, + totalSize: 3_000, + virtualizer: virtualizerWithRow1() + } as never) + harness.effects[0]?.effect() + + // The mount write's own clamped landing is classified programmatic. + emitScroll(2_120) + // Content above shrank further: the browser clamps again with no mark. + el.scrollHeight = 2_000 + emitScroll(1_120) + // Not a takeover: no anchor was recorded for the clamped position. + expect(anchorRef.current).toBeNull() + + // Content finished loading; a marked correction wakes the restore and the + // persisted offset is finally reachable. + el.scrollHeight = 30_000 + marks.mark(1_500) + emitScroll(1_500) + expect(el.scrollTop).toBe(4_000) + expect(scrollOffsetRef.current).toBe(4_000) + }) + + it('keeps rewriting toward the target while its own marked writes settle', async () => { + const { harness, useVirtualizedScrollAnchor } = await loadHook() + const marks = createProgrammaticScrollMarks() + const { el, emitScroll } = createScrollElement({ scrollTop: 0 }) + const anchorRef = { current: null } + + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getRowKey: (row: string) => row, + programmaticScrollMarks: marks, + recordAnchorOnScroll: false, + restoreSignal: 'signal-a', + rows: ['row-0', 'row-1'], + scrollElementRef: { current: el }, + scrollOffsetRef: { current: 4_000 }, + totalSize: 30_000, + virtualizer: virtualizerWithRow1() + } as never) + harness.effects[0]?.effect() + + // A marked write (e.g. a virtualizer correction) landed elsewhere while + // restoring; the restore should push back toward its target. + marks.mark(3_500) + emitScroll(3_500) + expect(el.scrollTop).toBe(4_000) + }) +}) diff --git a/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts b/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts index 7dd9764e7f6..4ffcdf7bae0 100644 --- a/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts +++ b/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts @@ -7,16 +7,22 @@ import { type RefObject } from 'react' import type { Virtualizer } from '@tanstack/react-virtual' -import { shouldCancelVirtualizedScrollOffsetRestore } from './virtualizedScrollOffsetRestore' import { findVirtualizedDomScrollAnchor, getVirtualizedScrollAnchorForOffset } from './virtualized-scroll-anchor-recording' +import { createVirtualizedScrollAnchorListener } from './virtualized-scroll-anchor-listener' +import { runVirtualizedScrollAnchorRestore } from './virtualized-scroll-anchor-restore' +import type { ProgrammaticScrollMarks } from './programmatic-scroll-marks' export type VirtualizedScrollAnchor = { fallbackKeys?: readonly string[] key: string offset: number + // Why: the scroll offset this anchor was derived from lets restore detect + // that the viewport moved (user scroll or clamp) after the anchor was + // recorded. Optional: anchors persisted before this field existed lack it. + scrollTop?: number } | null export const VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT = 'orca-record-virtualized-scroll-anchor' const RECORD_ANCHOR_SCROLL_IDLE_DELAY_MS = 150 @@ -31,10 +37,20 @@ type UseVirtualizedScrollAnchorOptions< getRowKey: (row: TRow) => string hasDirectScrollInput?: () => boolean itemElementSelector?: string + // Why: marks classify scroll events by origin (self-initiated vs user) + // instead of wall-clock input windows, which misclassify under main-thread + // jank. Callers opting in must route every programmatic scroll they issue + // through marks (including the virtualizer's scrollToFn). + programmaticScrollMarks?: ProgrammaticScrollMarks recordAnchorOnCleanup?: boolean // Why: some callers record user scroll anchors outside this hook; passive // programmatic scroll events during remount must not teach them a transient row. recordAnchorOnScroll?: boolean + // Why: when provided, anchor restore runs only when this value changes + // (structural row changes) or while a prior restore is still converging — + // not on every totalSize/isScrolling tick. Measurement-driven shifts are the + // virtualizer's own scroll-adjustment job. + restoreSignal?: string rows: readonly TRow[] scrollElementRef: RefObject scrollOffsetRef: MutableRefObject @@ -61,8 +77,10 @@ export function useVirtualizedScrollAnchor< getRowKey, hasDirectScrollInput, itemElementSelector, + programmaticScrollMarks, recordAnchorOnCleanup = true, recordAnchorOnScroll = true, + restoreSignal, rows, scrollElementRef, scrollOffsetRef, @@ -137,6 +155,13 @@ export function useVirtualizedScrollAnchor< recordAnchorOnCleanupRef.current = recordAnchorOnCleanup const recordAnchorOnScrollRef = useRef(recordAnchorOnScroll) recordAnchorOnScrollRef.current = recordAnchorOnScroll + const programmaticScrollMarksRef = useRef(programmaticScrollMarks) + programmaticScrollMarksRef.current = programmaticScrollMarks + const prevRestoreSignalRef = useRef(undefined) + // Why: true while a restore has written toward the anchor but the anchored + // row's position is not yet confirmed; re-arms the restore effect across + // totalSize ticks until it converges or the user scrolls. + const pendingRestoreRef = useRef(false) useLayoutEffect(() => { const el = scrollElementRef.current @@ -144,12 +169,6 @@ export function useVirtualizedScrollAnchor< return } - const targetOffset = scrollOffsetRef.current - let restoring = targetOffset > 0 - if (restoring) { - el.scrollTop = targetOffset - } - let frameId: number | null = null let idleTimerId: number | null = null const cancelScheduledRecord = (): void => { @@ -179,53 +198,31 @@ export function useVirtualizedScrollAnchor< scrollOffsetRef.current = el.scrollTop recordScrollAnchorRef.current(el.scrollTop) } - const onScroll = (): void => { - if ( - shouldCancelVirtualizedScrollOffsetRestore({ - hasDirectScrollInput: hasDirectScrollInputRef.current, - restoring - }) - ) { - // Why: direct wheel/touch input means the user has taken control of the - // viewport. Treat the current offset as intentional instead of snapping - // back to a stale persisted offset while restoration is still pending. - restoring = false - recordCurrentAnchor() - return - } - if (restoring) { - // Why: during a fresh virtualizer mount, total height may still be - // estimate-based. Avoid persisting a browser-clamped offset as the - // user's real position until the intended offset is reachable. - if (el.scrollTop === targetOffset) { - restoring = false - if (recordAnchorOnScrollRef.current) { - recordCurrentAnchor() - } else { - scrollOffsetRef.current = el.scrollTop - } - return + const onScroll = createVirtualizedScrollAnchorListener({ + el, + getHasDirectScrollInput: () => hasDirectScrollInputRef.current, + getMarks: () => programmaticScrollMarksRef.current, + getRecordAnchorOnScroll: () => recordAnchorOnScrollRef.current, + onProgrammaticScroll: (scrollTop) => { + // Why: our own settled writes must keep the divergence bookkeeping in + // sync, or the restore gate reads them as user scrolling and drops + // the next structural restore. + scrollOffsetRef.current = scrollTop + const anchor = anchorRef.current + if (anchor && anchor.scrollTop !== undefined) { + anchor.scrollTop = scrollTop } - if (el.scrollHeight - el.clientHeight >= targetOffset) { - el.scrollTop = targetOffset - if (el.scrollTop === targetOffset) { - restoring = false - if (recordAnchorOnScrollRef.current) { - recordCurrentAnchor() - } else { - scrollOffsetRef.current = el.scrollTop - } - } - } - return - } - if (!recordAnchorOnScrollRef.current) { - return - } - scrollOffsetRef.current = el.scrollTop - recordVirtualScrollAnchorRef.current(el.scrollTop) - scheduleRecordAnchor() - } + }, + pendingRestoreRef, + recordCurrentAnchor, + recordUserScroll: (scrollTop) => { + scrollOffsetRef.current = scrollTop + recordVirtualScrollAnchorRef.current(scrollTop) + scheduleRecordAnchor() + }, + targetOffset: scrollOffsetRef.current, + scrollOffsetRef + }) el.addEventListener('scroll', onScroll, { passive: true }) el.addEventListener(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, recordCurrentAnchor) @@ -238,7 +235,9 @@ export function useVirtualizedScrollAnchor< el.removeEventListener(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, recordCurrentAnchor) el.removeEventListener('scroll', onScroll) } - }, [scrollElementRef, scrollOffsetRef]) + // Why: only stable refs may appear here; row-derived values would rerun + // cleanup after a delete and overwrite the pre-delete anchor. + }, [anchorRef, scrollElementRef, scrollOffsetRef]) useLayoutEffect(() => { const anchor = anchorRef.current @@ -246,6 +245,33 @@ export function useVirtualizedScrollAnchor< if (!anchor || !el) { return } + if (restoreSignal !== undefined) { + const signalChanged = prevRestoreSignalRef.current !== restoreSignal + prevRestoreSignalRef.current = restoreSignal + if (!signalChanged && !pendingRestoreRef.current) { + // Why: no structural row change and no restore mid-convergence. Pure + // measurement churn is compensated by the virtualizer's own scroll + // adjustment; restoring here would fight concurrent user scrolling. + return + } + if (anchor.scrollTop !== undefined) { + const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight) + const clampExplained = + anchor.scrollTop > maxScrollTop + 1 && el.scrollTop >= maxScrollTop - 2 + if (Math.abs(el.scrollTop - anchor.scrollTop) > 1 && !clampExplained) { + // Why: the viewport moved after this anchor was recorded and no + // browser clamp explains it — the user scrolled. Their position + // wins; restoring would undo their input. + pendingRestoreRef.current = false + return + } + } + // Why: armed before the skip guards below, so a restore owed to a + // signal change survives being skipped during active input and retries + // on the next tick instead of being silently consumed. User scrolls + // disarm it via the scroll listener. + pendingRestoreRef.current = true + } if (virtualizer.isScrolling && hasDirectScrollInputRef.current?.() === true) { // Why: remeasurement during wheel scrolling can change totalSize. Restoring // the anchor in that window writes scrollTop and fights the user's wheel. @@ -256,89 +282,24 @@ export function useVirtualizedScrollAnchor< return } - const resolvedKey = rowIndexByKey.has(anchor.key) - ? anchor.key - : anchor.fallbackKeys?.find((key) => rowIndexByKey.has(key)) - if (!resolvedKey) { - return - } - const index = rowIndexByKey.get(resolvedKey) - if (index === undefined) { - return - } - const offset = resolvedKey === anchor.key ? anchor.offset : 0 - - const restoreFromDomElement = (): boolean => { - if (!itemElementSelector || !getItemElementKey) { - return false - } - const element = - Array.from(el.querySelectorAll(itemElementSelector)).find( - (candidate) => getItemElementKey(candidate) === resolvedKey && candidate.isConnected - ) ?? null - if (!element) { - return false - } - const scrollRect = el.getBoundingClientRect() - const rect = element.getBoundingClientRect() - const desiredTop = scrollRect.top - offset - const delta = rect.top - desiredTop - if (Math.abs(delta) > 1) { - // Why: this scroll write is still part of restore; keep the target - // anchor until a later layout confirms the intended row is in place. - el.scrollTop += delta - scrollOffsetRef.current = el.scrollTop - return true - } - scrollOffsetRef.current = el.scrollTop - recordScrollAnchor(el.scrollTop) - return true - } - - const restoreFromMeasuredItem = (): boolean => { - const item = virtualizer.getVirtualItems().find((candidate) => candidate.index === index) - if (!item) { - return false - } - const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight) - const nextScrollTop = Math.min(maxScrollTop, Math.max(0, item.start + offset)) - if (Math.abs(el.scrollTop - nextScrollTop) > 1) { - el.scrollTop = nextScrollTop - } - // Why: measured fallback can run while TanStack's virtual window is - // transitional, so recording here can replace the target with a wrong row. - scrollOffsetRef.current = el.scrollTop - return true - } - - if (restoreFromDomElement()) { - return - } - - // Why: right after a delete the virtualizer can briefly render the wrong - // window, so the anchor row's DOM node isn't mounted yet even though the - // virtualizer still has its measured slot. Pin from that measured start - // (preserving the within-row offset) before falling back to scrollToIndex, - // whose align:'start' snaps the row to the viewport top and visibly jumps. - if (restoreFromMeasuredItem()) { - return - } - - // Why: the anchored row is outside the virtualizer's current window — no - // DOM node and no measured slot. Bring it in, then apply the within-row - // offset once TanStack Virtual has mounted and measured that row. - virtualizer.scrollToIndex(index, { align: 'start' }) - const frameId = window.requestAnimationFrame(() => { - if (!restoreFromDomElement()) { - restoreFromMeasuredItem() - } + return runVirtualizedScrollAnchorRestore({ + anchor, + el, + getItemElementKey, + itemElementSelector, + pendingRestoreRef, + programmaticScrollMarks: programmaticScrollMarksRef.current, + recordScrollAnchor, + rowIndexByKey, + scrollOffsetRef, + virtualizer }) - return () => window.cancelAnimationFrame(frameId) }, [ anchorRef, getItemElementKey, itemElementSelector, recordScrollAnchor, + restoreSignal, rowIndexByKey, scrollElementRef, scrollOffsetRef, diff --git a/src/renderer/src/hooks/virtualized-scroll-anchor-listener.ts b/src/renderer/src/hooks/virtualized-scroll-anchor-listener.ts new file mode 100644 index 00000000000..b787e75ebd0 --- /dev/null +++ b/src/renderer/src/hooks/virtualized-scroll-anchor-listener.ts @@ -0,0 +1,140 @@ +import type { MutableRefObject } from 'react' +import { shouldCancelVirtualizedScrollOffsetRestore } from './virtualizedScrollOffsetRestore' +import type { ProgrammaticScrollMarks } from './programmatic-scroll-marks' + +type CreateVirtualizedScrollAnchorListenerArgs = { + el: TScrollElement + getHasDirectScrollInput: () => (() => boolean) | undefined + getMarks: () => ProgrammaticScrollMarks | undefined + getRecordAnchorOnScroll: () => boolean + // Why: our own writes (virtualizer size adjustments, restore nudges) move + // the viewport; the owner must sync its bookkeeping (scrollOffset, anchor + // scrollTop) or a later divergence check reads the moved offset as user + // input and drops a due restore. + onProgrammaticScroll: (scrollTop: number) => void + pendingRestoreRef: MutableRefObject + recordCurrentAnchor: () => void + recordUserScroll: (scrollTop: number) => void + targetOffset: number + scrollOffsetRef: MutableRefObject +} + +/** + * Owns the mount-time offset restore and classifies subsequent scroll events. + * With marks, classification is state-owned: a scroll is the user's exactly + * when no registered programmatic write (or its clamped landing) explains it. + * Without marks, falls back to the legacy direct-input time window. + * Writes the initial offset on creation when one is persisted. + */ +export function createVirtualizedScrollAnchorListener({ + el, + getHasDirectScrollInput, + getMarks, + getRecordAnchorOnScroll, + onProgrammaticScroll, + pendingRestoreRef, + recordCurrentAnchor, + recordUserScroll, + targetOffset, + scrollOffsetRef +}: CreateVirtualizedScrollAnchorListenerArgs): (event: Event) => void { + // Why: a write to the current position emits no scroll event, so an armed + // restore would never complete and would snap later marked writes back to + // this stale target. Nothing to restore — don't arm. + let restoring = targetOffset > 0 && el.scrollTop !== targetOffset + if (restoring) { + getMarks()?.mark(targetOffset) + el.scrollTop = targetOffset + } + + const completeRestore = (): void => { + restoring = false + if (getRecordAnchorOnScroll()) { + recordCurrentAnchor() + } else { + scrollOffsetRef.current = el.scrollTop + } + } + + return (event: Event): void => { + const marks = getMarks() + if (marks) { + const isProgrammatic = marks.consume(event, el.scrollTop, el.scrollHeight - el.clientHeight) + if (restoring) { + if (el.scrollTop === targetOffset) { + completeRestore() + return + } + if (!isProgrammatic) { + const maxScrollOffset = el.scrollHeight - el.clientHeight + // Why: an unmarked landing pinned at a max the target can't reach + // is the browser clamping after content shrank, not user input; + // keep waiting for the range to catch up instead of persisting the + // clamped position. Direct input still takes over immediately. + const clampExplainsLanding = + targetOffset > maxScrollOffset && el.scrollTop >= maxScrollOffset - 2 + if (!clampExplainsLanding || getHasDirectScrollInput()?.() === true) { + // Why: an unmarked scroll no clamp explains is the user taking + // control of the viewport (marks classify our writes and their + // clamped landings); their position wins over the persisted offset. + restoring = false + pendingRestoreRef.current = false + recordCurrentAnchor() + return + } + } + if (el.scrollHeight - el.clientHeight >= targetOffset) { + marks.mark(targetOffset) + el.scrollTop = targetOffset + if (el.scrollTop === targetOffset) { + completeRestore() + } + } + return + } + if (isProgrammatic) { + onProgrammaticScroll(el.scrollTop) + return + } + pendingRestoreRef.current = false + if (!getRecordAnchorOnScroll()) { + return + } + recordUserScroll(el.scrollTop) + return + } + if ( + shouldCancelVirtualizedScrollOffsetRestore({ + hasDirectScrollInput: getHasDirectScrollInput(), + restoring + }) + ) { + // Why: direct wheel/touch input means the user has taken control of the + // viewport. Treat the current offset as intentional instead of snapping + // back to a stale persisted offset while restoration is still pending. + restoring = false + recordCurrentAnchor() + return + } + if (restoring) { + // Why: during a fresh virtualizer mount, total height may still be + // estimate-based. Avoid persisting a browser-clamped offset as the + // user's real position until the intended offset is reachable. + if (el.scrollTop === targetOffset) { + completeRestore() + return + } + if (el.scrollHeight - el.clientHeight >= targetOffset) { + el.scrollTop = targetOffset + if (el.scrollTop === targetOffset) { + completeRestore() + } + } + return + } + if (!getRecordAnchorOnScroll()) { + return + } + recordUserScroll(el.scrollTop) + } +} diff --git a/src/renderer/src/hooks/virtualized-scroll-anchor-recording.ts b/src/renderer/src/hooks/virtualized-scroll-anchor-recording.ts index bf77d2426ff..43855b50ffd 100644 --- a/src/renderer/src/hooks/virtualized-scroll-anchor-recording.ts +++ b/src/renderer/src/hooks/virtualized-scroll-anchor-recording.ts @@ -42,7 +42,8 @@ export function findVirtualizedDomScrollAnchor({ return { fallbackKeys: visibleItems.slice(1).map((item) => item.key), key: firstVisible.key, - offset: Math.min(firstVisible.rect.height, Math.max(0, scrollRect.top - firstVisible.rect.top)) + offset: Math.min(firstVisible.rect.height, Math.max(0, scrollRect.top - firstVisible.rect.top)), + scrollTop: scrollElement.scrollTop } } @@ -70,6 +71,7 @@ export function getVirtualizedScrollAnchorForOffset({ .filter((row): row is TRow => row != null) .map(getRowKey), key: getRowKey(row), - offset: Math.max(0, scrollTop - firstVisible.start) + offset: Math.max(0, scrollTop - firstVisible.start), + scrollTop } } diff --git a/src/renderer/src/hooks/virtualized-scroll-anchor-restore.ts b/src/renderer/src/hooks/virtualized-scroll-anchor-restore.ts new file mode 100644 index 00000000000..083e95acb13 --- /dev/null +++ b/src/renderer/src/hooks/virtualized-scroll-anchor-restore.ts @@ -0,0 +1,148 @@ +import type { MutableRefObject } from 'react' +import type { Virtualizer } from '@tanstack/react-virtual' +import type { ProgrammaticScrollMarks } from './programmatic-scroll-marks' +import type { VirtualizedScrollAnchor } from './useVirtualizedScrollAnchor' + +type RunVirtualizedScrollAnchorRestoreArgs< + TScrollElement extends Element, + TItemElement extends Element +> = { + anchor: NonNullable + el: TScrollElement + getItemElementKey?: (element: TItemElement) => string | null + itemElementSelector?: string + // Why: true while a restore has written toward the anchor but the anchored + // row's position is not yet confirmed; the caller re-runs the restore across + // measurement ticks until it converges or the user scrolls. + pendingRestoreRef: MutableRefObject + programmaticScrollMarks?: ProgrammaticScrollMarks + recordScrollAnchor: (scrollTop: number) => void + rowIndexByKey: ReadonlyMap + scrollOffsetRef: MutableRefObject + virtualizer: Virtualizer +} + +/** + * One restore attempt: pin the anchored row back to its recorded viewport + * offset. Prefers the row's live DOM position, then its measured virtual slot, + * then scrollToIndex to bring it into the window. Every scroll write is + * registered with the marks so scroll listeners attribute it to this code, + * not the user. Returns a cleanup for the scrollToIndex follow-up frame. + */ +export function runVirtualizedScrollAnchorRestore< + TScrollElement extends Element, + TItemElement extends Element +>({ + anchor, + el, + getItemElementKey, + itemElementSelector, + pendingRestoreRef, + programmaticScrollMarks, + recordScrollAnchor, + rowIndexByKey, + scrollOffsetRef, + virtualizer +}: RunVirtualizedScrollAnchorRestoreArgs): (() => void) | undefined { + const resolvedKey = rowIndexByKey.has(anchor.key) + ? anchor.key + : anchor.fallbackKeys?.find((key) => rowIndexByKey.has(key)) + if (!resolvedKey) { + // Why: an unresolvable anchor has nothing to converge; rows that make it + // resolvable again always arrive via a signal change, so leaving the arm + // set would re-run restore on every measurement tick. + pendingRestoreRef.current = false + return + } + const index = rowIndexByKey.get(resolvedKey) + if (index === undefined) { + pendingRestoreRef.current = false + return + } + const offset = resolvedKey === anchor.key ? anchor.offset : 0 + const canConfirmFromDom = Boolean(itemElementSelector && getItemElementKey) + + const restoreFromDomElement = (): boolean => { + if (!itemElementSelector || !getItemElementKey) { + return false + } + const element = + Array.from(el.querySelectorAll(itemElementSelector)).find( + (candidate) => getItemElementKey(candidate) === resolvedKey && candidate.isConnected + ) ?? null + if (!element) { + return false + } + const scrollRect = el.getBoundingClientRect() + const rect = element.getBoundingClientRect() + const desiredTop = scrollRect.top - offset + const delta = rect.top - desiredTop + if (Math.abs(delta) > 1) { + // Why: this scroll write is still part of restore; keep the target + // anchor until a later layout confirms the intended row is in place. + programmaticScrollMarks?.mark(el.scrollTop + delta) + el.scrollTop += delta + scrollOffsetRef.current = el.scrollTop + anchor.scrollTop = el.scrollTop + pendingRestoreRef.current = true + return true + } + scrollOffsetRef.current = el.scrollTop + pendingRestoreRef.current = false + recordScrollAnchor(el.scrollTop) + return true + } + + const restoreFromMeasuredItem = (): boolean => { + const item = virtualizer.getVirtualItems().find((candidate) => candidate.index === index) + if (!item) { + return false + } + const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight) + const nextScrollTop = Math.min(maxScrollTop, Math.max(0, item.start + offset)) + if (Math.abs(el.scrollTop - nextScrollTop) > 1) { + programmaticScrollMarks?.mark(nextScrollTop) + el.scrollTop = nextScrollTop + } + // Why: measured fallback can run while TanStack's virtual window is + // transitional, so recording here can replace the target with a wrong row. + scrollOffsetRef.current = el.scrollTop + anchor.scrollTop = el.scrollTop + // Why: without a DOM selector there is no later confirmation step; the + // measured landing is final, so don't re-arm restore ticks forever. + pendingRestoreRef.current = canConfirmFromDom + return true + } + + if (restoreFromDomElement()) { + return + } + + // Why: right after a delete the virtualizer can briefly render the wrong + // window, so the anchor row's DOM node isn't mounted yet even though the + // virtualizer still has its measured slot. Pin from that measured start + // (preserving the within-row offset) before falling back to scrollToIndex, + // whose align:'start' snaps the row to the viewport top and visibly jumps. + if (restoreFromMeasuredItem()) { + return + } + + // Why: the anchored row is outside the virtualizer's current window — no + // DOM node and no measured slot. Bring it in, then apply the within-row + // offset once TanStack Virtual has mounted and measured that row. + // scrollToIndex marks its write via the caller-supplied scrollToFn. + pendingRestoreRef.current = true + virtualizer.scrollToIndex(index, { align: 'start' }) + anchor.scrollTop = el.scrollTop + const frameId = window.requestAnimationFrame(() => { + if (!pendingRestoreRef.current) { + // Why: an unmarked (user) scroll disarmed the restore between the + // effect and this frame; writing now would fight their input. + return + } + if (!restoreFromDomElement()) { + restoreFromMeasuredItem() + } + }) + return () => window.cancelAnimationFrame(frameId) +} From f495b39a6a2b470c82818ab2a28f5154338ebc3d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:33:55 -0700 Subject: [PATCH 11/17] Fix untracked line-stat cache thrash and add source-control scale benchmark (#8022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix untracked line-stat cache thrash and add source-control scale benchmark (#8013) The untracked line-stat cache capped at 2,048 entries while a git status scan can carry up to DEFAULT_GIT_STATUS_LIMIT (10,000) untracked entries. A sequential scan over more files than the cap FIFO-evicted every entry before the next poll revisited it (~0% hit rate), so every 3s status poll re-read every untracked file's full contents. Measured with 64KB files: warm rescan cost per file was 17x higher just past the cap. - Size the cache to 2x the status entry limit and make eviction LRU (delete-before-set on hit and refresh) so a hot worktree's entries survive another worktree's scan. - Add tests/e2e/source-control-large-file-count.spec.ts: a 5-scenario Playwright benchmark (pnpm run test:e2e:source-control-scale) that reproduces #8013 deterministically — event-loop stall, DOM node count, JS heap, and OS-level renderer working set at 5k/9.5k/11k changed files, plus a clean-repo control and a cache-effectiveness gate. Scenarios asserting bounded row mounting go green with the SourceControl virtualization fix (#7619). Co-authored-by: Orca * Address review: historical cache comment + fixture cleanup on partial setup failure Co-authored-by: Orca --------- Co-authored-by: Orca --- package.json | 1 + src/shared/git-uncommitted-line-stats.test.ts | 17 + src/shared/git-uncommitted-line-stats.ts | 17 +- tests/e2e/large-file-count-fixtures.ts | 133 ++++++ .../source-control-large-file-count.spec.ts | 435 ++++++++++++++++++ 5 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/large-file-count-fixtures.ts create mode 100644 tests/e2e/source-control-large-file-count.spec.ts diff --git a/package.json b/package.json index a24b1d1d669..de31411d156 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs", "test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs", "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", + "test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "win-update-e2e": "node tools/win-update-e2e/run.mjs", "test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs", "test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful", diff --git a/src/shared/git-uncommitted-line-stats.test.ts b/src/shared/git-uncommitted-line-stats.test.ts index fc98a3d8210..d9e876113aa 100644 --- a/src/shared/git-uncommitted-line-stats.test.ts +++ b/src/shared/git-uncommitted-line-stats.test.ts @@ -135,6 +135,23 @@ describe('collectUntrackedAdditions', () => { expect(stats.get('cached.ts')).toEqual({ added: 3 }) expect(readFileMock).toHaveBeenCalledTimes(1) }) + + it('keeps the cache effective across polls for a status-limit-sized change set', async () => { + // Why: git status caps at DEFAULT_GIT_STATUS_LIMIT (10,000) entries. A + // cache smaller than one scan FIFO-evicts every entry mid-scan, so the + // next poll re-reads every file (#8013). Scan the full limit twice; the + // second pass must be stat-only. + lstatMock.mockResolvedValue(mockFileStat(5, 7)) + readFileMock.mockResolvedValue(Buffer.from('a\nb\nc')) + const paths = Array.from({ length: 10_000 }, (_, i) => `poll-scale/file-${i}.ts`) + + await collectUntrackedAdditions('/repo', paths) + const firstPassReads = readFileMock.mock.calls.length + await collectUntrackedAdditions('/repo', paths) + + expect(firstPassReads).toBe(paths.length) + expect(readFileMock).toHaveBeenCalledTimes(paths.length) + }) }) describe('applyLineStats', () => { diff --git a/src/shared/git-uncommitted-line-stats.ts b/src/shared/git-uncommitted-line-stats.ts index 44f45671d4d..3fe4c5cc99d 100644 --- a/src/shared/git-uncommitted-line-stats.ts +++ b/src/shared/git-uncommitted-line-stats.ts @@ -2,6 +2,7 @@ import { lstat, readFile } from 'node:fs/promises' import * as path from 'node:path' import { isBinaryBuffer } from './binary-buffer' import { decodeGitCQuotedPath } from './git-cquoted-path' +import { DEFAULT_GIT_STATUS_LIMIT } from './git-status-limit' export type GitLineStats = { added?: number; removed?: number } @@ -11,7 +12,13 @@ const UNTRACKED_READ_CONCURRENCY = 8 // Keep status polling cheap: large untracked files are commonly generated // assets, and reading them every poll can stall the source-control sidebar. export const MAX_UNTRACKED_LINE_COUNT_BYTES = 2 * 1024 * 1024 -const UNTRACKED_STATS_CACHE_MAX_ENTRIES = 2048 +// Why: the cache must hold at least one full status scan's untracked set +// (capped at DEFAULT_GIT_STATUS_LIMIT entries). A smaller cache is worse than +// none: a sequential scan over more files than the cap evicts every entry +// before the next poll revisits it, so every poll re-reads every untracked +// file's contents (#8013). 2x leaves headroom for a second window polling a +// different worktree; entries are ~200 bytes, so worst case is a few MB. +const UNTRACKED_STATS_CACHE_MAX_ENTRIES = 2 * DEFAULT_GIT_STATUS_LIMIT const NEWLINE_BYTE = 0x0a type CachedUntrackedStats = { @@ -107,6 +114,11 @@ async function countFileAdditions(absolutePath: string): Promise { cached.mtimeMs === fileStat.mtimeMs && cached.ctimeMs === fileStat.ctimeMs ) { + // Why: Map eviction below removes the oldest-inserted key; re-inserting + // on hit makes that LRU instead of FIFO, so a hot worktree's entries + // survive another worktree's scan sharing this cache. + untrackedStatsCache.delete(absolutePath) + untrackedStatsCache.set(absolutePath, cached) return cached.stats } if (fileStat.isSymbolicLink()) { @@ -144,6 +156,9 @@ function rememberUntrackedStats( fileStat: { size: number; mtimeMs: number; ctimeMs: number }, stats: GitLineStats ): GitLineStats { + // Why: delete-before-set keeps refreshed entries at the recent end of the + // Map's insertion order, preserving the LRU eviction contract. + untrackedStatsCache.delete(absolutePath) untrackedStatsCache.set(absolutePath, { size: fileStat.size, mtimeMs: fileStat.mtimeMs, diff --git a/tests/e2e/large-file-count-fixtures.ts b/tests/e2e/large-file-count-fixtures.ts new file mode 100644 index 00000000000..f3411c02bc5 --- /dev/null +++ b/tests/e2e/large-file-count-fixtures.ts @@ -0,0 +1,133 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +export type LargeFileCountRepoOptions = { + /** Files committed on the initial commit. */ + trackedFiles?: number + /** Files written after the commit and left untracked. */ + untrackedFiles?: number + /** Tracked files rewritten after the commit (unstaged modifications). */ + modifiedFiles?: number + /** Fan-out per directory; issue #8013 repos are wide trees, not one flat dir. */ + filesPerDirectory?: number + /** + * Minimum size of each untracked file. Untracked line stats read full file + * contents (mtime-cached), so per-poll I/O cost only shows up when + * untracked files have realistic sizes. + */ + untrackedFileBytes?: number +} + +export type LargeFileCountRepo = { + repoPath: string + trackedFiles: number + untrackedFiles: number + modifiedFiles: number +} + +function runGit(repoPath: string, args: string[]): void { + execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' }) +} + +/** + * Removes a fixture repo, retrying ENOTEMPTY/EBUSY races: the app's git status + * poll can still be writing index.lock (or the watcher holding dirs) while the + * test tears down. + */ +export async function removeLargeFileCountRepo(repoPath: string): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + rmSync(repoPath, { recursive: true, force: true }) + return + } catch (error) { + if (attempt === 4) { + throw error + } + await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1))) + } + } +} + +function writeFileTree( + repoPath: string, + rootDirName: string, + fileCount: number, + filesPerDirectory: number, + contentForIndex: (index: number) => string +): void { + let currentDir: string | null = null + for (let index = 0; index < fileCount; index += 1) { + const dirIndex = Math.floor(index / filesPerDirectory) + const dirPath = path.join(repoPath, rootDirName, `dir-${String(dirIndex).padStart(4, '0')}`) + if (dirPath !== currentDir) { + mkdirSync(dirPath, { recursive: true }) + currentDir = dirPath + } + writeFileSync( + path.join(dirPath, `file-${String(index).padStart(6, '0')}.ts`), + contentForIndex(index) + ) + } +} + +/** + * Repro fixture for issue #8013: a repo whose file count (tracked, untracked, + * or modified) is large enough to stress every per-file code path — the + * streamed `git status` scan, per-entry line stats, the IPC payload, and the + * Source Control row rendering. + */ +export function createLargeFileCountRepo( + options: LargeFileCountRepoOptions = {} +): LargeFileCountRepo { + const trackedFiles = options.trackedFiles ?? 0 + const untrackedFiles = options.untrackedFiles ?? 0 + const modifiedFiles = Math.min(options.modifiedFiles ?? 0, trackedFiles) + const filesPerDirectory = options.filesPerDirectory ?? 100 + + const repoPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-large-file-count-'))) + runGit(repoPath, ['init']) + runGit(repoPath, ['config', 'user.email', 'e2e@test.local']) + runGit(repoPath, ['config', 'user.name', 'E2E Test']) + runGit(repoPath, ['config', 'core.autocrlf', 'false']) + + writeFileSync(path.join(repoPath, 'README.md'), '# Large file count fixture\n') + writeFileTree( + repoPath, + 'src', + trackedFiles, + filesPerDirectory, + (index) => `export const tracked${index} = ${index}\n` + ) + runGit(repoPath, ['add', '-A']) + runGit(repoPath, ['commit', '-m', 'Initial large file count fixture', '--quiet']) + + const untrackedFileBytes = options.untrackedFileBytes ?? 0 + const untrackedPadding = + untrackedFileBytes > 0 + ? `// ${'x'.repeat(78)}\n`.repeat(Math.ceil(untrackedFileBytes / 81)) + : '' + writeFileTree( + repoPath, + 'generated', + untrackedFiles, + filesPerDirectory, + (index) => `export const untracked${index} = ${index}\n${untrackedPadding}` + ) + + for (let index = 0; index < modifiedFiles; index += 1) { + const dirIndex = Math.floor(index / filesPerDirectory) + writeFileSync( + path.join( + repoPath, + 'src', + `dir-${String(dirIndex).padStart(4, '0')}`, + `file-${String(index).padStart(6, '0')}.ts` + ), + `export const tracked${index} = ${index}\nexport const modified${index} = true\n` + ) + } + + return { repoPath, trackedFiles, untrackedFiles, modifiedFiles } +} diff --git a/tests/e2e/source-control-large-file-count.spec.ts b/tests/e2e/source-control-large-file-count.spec.ts new file mode 100644 index 00000000000..2919c0e0ed8 --- /dev/null +++ b/tests/e2e/source-control-large-file-count.spec.ts @@ -0,0 +1,435 @@ +/** + * Benchmark + repro suite for issue #8013: repos with ~10,000+ files make the + * renderer's memory grow and the UI unresponsive. + * + * Each scenario builds an isolated on-disk repo at a controlled scale, drives + * the real status pipeline (git scan → line stats → IPC → store → Source + * Control rows), and measures: + * - scanMs main-process `git status` + line-stat duration + * - payloadBytes serialized status payload size crossing IPC + * - renderMs time from setGitStatus until rows are in the DOM + * - maxLagMs/p95 event-loop lag while loading (UI responsiveness) + * - domNodeCount total DOM nodes after render + * - heap growth JS heap across repeated poll cycles (leak signal) + * + * Run a single scenario at a custom scale: + * ORCA_LARGE_FILE_COUNT=9500 npx playwright test \ + * tests/e2e/source-control-large-file-count.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless + */ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { createLargeFileCountRepo, removeLargeFileCountRepo } from './large-file-count-fixtures' +import { DEFAULT_GIT_STATUS_LIMIT } from '../../src/shared/git-status-limit' + +// Matches the large-diff freeze budget: a blocking stall past 1s is the +// "UI becomes unresponsive" symptom reported in #8013. +const MAX_EVENT_LOOP_LAG_MS = 1_000 +// A second full status→store→render cycle with identical data must not leak +// unbounded memory; allow generous headroom for GC timing noise. +const MAX_HEAP_GROWTH_PER_CYCLE_MB = 75 + +// A virtualized list mounts viewport + overscan rows only; anything past this +// bound means the panel is mounting rows proportional to the change set again. +const MAX_MOUNTED_ROWS = 1_000 + +type LoadMeasurement = { + entryCount: number + didHitLimit: boolean + scanMs: number + rescanMs: number + payloadBytes: number + renderMs: number + renderedRows: number + maxLagMs: number + p95LagMs: number + domNodeCount: number + heapUsedMbAfterRender: number + heapUsedMbPerCycle: number[] + cycleMaxLagMs: number[] +} + +async function addAndActivateRepo(orcaPage: Page, repoPath: string): Promise { + const repoId = await orcaPage.evaluate(async (pathToRepo: string) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const addedRepo = await store.getState().addRepoPath(pathToRepo) + if (!addedRepo) { + throw new Error(`isolated repo not found: ${pathToRepo}`) + } + return addedRepo.id + }, repoPath) + + // Why: fetchWorktrees() resolves before Zustand always reflects the async + // worktree scan, so poll the same public store path real repo setup uses. + await expect + .poll( + () => + orcaPage.evaluate(async (targetRepoId: string) => { + const store = window.__store + if (!store) { + return 0 + } + await store.getState().fetchWorktrees(targetRepoId) + return store.getState().worktreesByRepo[targetRepoId]?.length ?? 0 + }, repoId), + { timeout: 30_000, message: 'isolated large-file-count worktree did not load' } + ) + .toBeGreaterThan(0) + + return await orcaPage.evaluate( + ({ targetRepoId, pathToRepo }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const worktrees = state.worktreesByRepo[targetRepoId] ?? [] + const worktree = worktrees.find((entry) => entry.path === pathToRepo) ?? worktrees[0] + if (!worktree) { + throw new Error(`isolated worktree not found: ${pathToRepo}`) + } + state.setActiveRepo(targetRepoId) + state.setActiveWorktree(worktree.id) + state.setRightSidebarOpen(true) + state.setRightSidebarTab('source-control') + return worktree.id + }, + { targetRepoId: repoId, pathToRepo: repoPath } + ) +} + +/** + * Drives the exact pipeline the panel's poll uses (api.git.status → + * setGitStatus) so results are deterministic even while the built-in 3s poll + * runs concurrently — the poll produces identical entries, which the store + * dedupes. + */ +async function measureSourceControlLoad( + orcaPage: Page, + args: { worktreeId: string; repoPath: string; expectedRows: number; pollCycles: number } +): Promise { + return await orcaPage.evaluate(async ({ worktreeId, repoPath, expectedRows, pollCycles }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const lagSamples: number[] = [] + const probeIntervalMs = 50 + let lastTick = performance.now() + const probe = window.setInterval(() => { + const now = performance.now() + lagSamples.push(Math.max(0, now - lastTick - probeIntervalMs)) + lastTick = now + }, probeIntervalMs) + + const readHeapMb = (): number => { + const memory = (performance as unknown as { memory?: { usedJSHeapSize: number } }).memory + return memory ? memory.usedJSHeapSize / (1024 * 1024) : -1 + } + + try { + const scanStart = performance.now() + const status = await window.api.git.status({ worktreePath: repoPath }) + const scanMs = performance.now() - scanStart + const payloadBytes = JSON.stringify(status).length + + // Why: a virtualized panel mounts only viewport rows, so "all rows in + // the DOM" would never happen post-fix. First rows appearing is the + // user-visible "list loaded" moment either way. + const firstRowsTarget = Math.min(expectedRows, 30) + const renderStart = performance.now() + store.getState().setGitStatus(worktreeId, status) + let renderedRows = 0 + while (performance.now() - renderStart < 60_000) { + renderedRows = document.querySelectorAll('[data-testid="source-control-entry"]').length + if (renderedRows >= firstRowsTarget) { + break + } + await new Promise((resolve) => window.setTimeout(resolve, 50)) + } + const renderMs = performance.now() - renderStart + // Settle so the lag probe captures post-render layout/paint stalls too. + await new Promise((resolve) => window.setTimeout(resolve, 1_000)) + renderedRows = document.querySelectorAll('[data-testid="source-control-entry"]').length + const heapUsedMbAfterRender = readHeapMb() + + // Why: #8013 reports memory that keeps growing — replay the poll cycle + // (fresh scan, fresh entry array, store update) and track heap + lag. + const heapUsedMbPerCycle: number[] = [] + const cycleMaxLagMs: number[] = [] + let rescanMs = 0 + for (let cycle = 0; cycle < pollCycles; cycle += 1) { + const cycleLagStart = lagSamples.length + const cycleScanStart = performance.now() + const cycleStatus = await window.api.git.status({ worktreePath: repoPath }) + if (cycle === 0) { + // First rescan isolates warm-cache scan cost (untracked line-stat + // reads are mtime-cached after the initial scan). + rescanMs = performance.now() - cycleScanStart + } + store.getState().setGitStatus(worktreeId, cycleStatus) + await new Promise((resolve) => window.setTimeout(resolve, 500)) + heapUsedMbPerCycle.push(readHeapMb()) + cycleMaxLagMs.push(Math.max(0, ...lagSamples.slice(cycleLagStart))) + } + + const sorted = [...lagSamples].sort((a, b) => a - b) + return { + entryCount: status.entries.length, + didHitLimit: status.didHitLimit === true, + scanMs, + rescanMs, + payloadBytes, + renderMs, + renderedRows, + maxLagMs: sorted.length ? (sorted.at(-1) ?? 0) : 0, + p95LagMs: sorted.length ? sorted[Math.floor(sorted.length * 0.95)] : 0, + domNodeCount: document.getElementsByTagName('*').length, + heapUsedMbAfterRender, + heapUsedMbPerCycle, + cycleMaxLagMs + } + } finally { + window.clearInterval(probe) + } + }, args) +} + +function logMeasurement( + label: string, + measurement: LoadMeasurement & { rendererWorkingSetMb?: { before: number; after: number } } +): void { + console.log(`[large-file-count] ${label} ${JSON.stringify(measurement)}`) +} + +/** + * OS-level working set of all renderer processes, in MB. The #8013 report is + * about renderer memory; JS heap alone misses DOM/native memory, which + * dominates when hundreds of thousands of nodes mount. + */ +async function readRendererWorkingSetMb(electronApp: ElectronApplication): Promise { + return await electronApp.evaluate(({ app }) => { + let totalKb = 0 + for (const metric of app.getAppMetrics()) { + if (metric.type === 'Tab') { + totalKb += metric.memory.workingSetSize + } + } + return totalKb / 1024 + }) +} + +test.describe('Source Control large file count (#8013)', () => { + // Why: each scenario gets its own Electron app + isolated fixture repo, so a + // failing scale must not skip the others — every scenario is a data point. + test.use({ seedTestRepo: false }) + + test('thousands of untracked files under the status cap stay responsive', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(600_000) + const untrackedFiles = Number(process.env.ORCA_LARGE_FILE_COUNT ?? '9500') + // Why: ORCA_LARGE_FILE_BYTES gives untracked files realistic sizes so the + // per-poll line-stat reads (cache-capped at 2,048 entries) become visible + // in rescanMs instead of hiding behind ~30-byte fixture files. + const untrackedFileBytes = Number(process.env.ORCA_LARGE_FILE_BYTES ?? '0') + const fixture = createLargeFileCountRepo({ + trackedFiles: 100, + untrackedFiles, + untrackedFileBytes + }) + try { + await waitForSessionReady(orcaPage) + const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) + const workingSetBeforeMb = await readRendererWorkingSetMb(electronApp) + const measurement = await measureSourceControlLoad(orcaPage, { + worktreeId, + repoPath: fixture.repoPath, + expectedRows: untrackedFiles, + pollCycles: 3 + }) + const workingSetAfterMb = await readRendererWorkingSetMb(electronApp) + logMeasurement(`untracked=${untrackedFiles}`, { + ...measurement, + rendererWorkingSetMb: { before: workingSetBeforeMb, after: workingSetAfterMb } + }) + + expect(measurement.didHitLimit).toBe(false) + expect(measurement.entryCount).toBeGreaterThanOrEqual(untrackedFiles) + expect(measurement.renderedRows).toBeGreaterThan(0) + // Why: mounting rows proportional to the change set is the #8013 bug; + // a virtualized panel mounts viewport + overscan only. + expect(measurement.renderedRows).toBeLessThan(MAX_MOUNTED_ROWS) + expect(measurement.maxLagMs).toBeLessThan(MAX_EVENT_LOOP_LAG_MS) + if (measurement.heapUsedMbPerCycle.length > 1) { + const first = measurement.heapUsedMbPerCycle[0] + const last = measurement.heapUsedMbPerCycle.at(-1) ?? first + expect(last - first).toBeLessThan( + MAX_HEAP_GROWTH_PER_CYCLE_MB * (measurement.heapUsedMbPerCycle.length - 1) + ) + } + } finally { + await removeLargeFileCountRepo(fixture.repoPath) + } + }) + + test('thousands of modified tracked files under the status cap stay responsive', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(600_000) + const modifiedFiles = Number(process.env.ORCA_LARGE_FILE_COUNT ?? '5000') + const fixture = createLargeFileCountRepo({ trackedFiles: modifiedFiles, modifiedFiles }) + try { + await waitForSessionReady(orcaPage) + const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) + const workingSetBeforeMb = await readRendererWorkingSetMb(electronApp) + const measurement = await measureSourceControlLoad(orcaPage, { + worktreeId, + repoPath: fixture.repoPath, + expectedRows: modifiedFiles, + pollCycles: 3 + }) + const workingSetAfterMb = await readRendererWorkingSetMb(electronApp) + logMeasurement(`modified=${modifiedFiles}`, { + ...measurement, + rendererWorkingSetMb: { before: workingSetBeforeMb, after: workingSetAfterMb } + }) + + expect(measurement.didHitLimit).toBe(false) + expect(measurement.entryCount).toBeGreaterThanOrEqual(modifiedFiles) + expect(measurement.renderedRows).toBeGreaterThan(0) + expect(measurement.renderedRows).toBeLessThan(MAX_MOUNTED_ROWS) + expect(measurement.maxLagMs).toBeLessThan(MAX_EVENT_LOOP_LAG_MS) + } finally { + await removeLargeFileCountRepo(fixture.repoPath) + } + }) + + test('a change set over the status cap degrades to the too-many-changes state', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(600_000) + const untrackedFiles = DEFAULT_GIT_STATUS_LIMIT + 1_000 + const fixture = createLargeFileCountRepo({ trackedFiles: 100, untrackedFiles }) + try { + await waitForSessionReady(orcaPage) + const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) + const workingSetBeforeMb = await readRendererWorkingSetMb(electronApp) + const measurement = await measureSourceControlLoad(orcaPage, { + worktreeId, + repoPath: fixture.repoPath, + // The capped payload still carries DEFAULT_GIT_STATUS_LIMIT entries; + // the panel may render them, but must stay responsive while doing so. + expectedRows: 0, + pollCycles: 1 + }) + const workingSetAfterMb = await readRendererWorkingSetMb(electronApp) + logMeasurement(`over-cap untracked=${untrackedFiles}`, { + ...measurement, + rendererWorkingSetMb: { before: workingSetBeforeMb, after: workingSetAfterMb } + }) + + expect(measurement.didHitLimit).toBe(true) + expect(measurement.entryCount).toBeLessThanOrEqual(DEFAULT_GIT_STATUS_LIMIT) + expect(measurement.renderedRows).toBeLessThan(MAX_MOUNTED_ROWS) + expect(measurement.maxLagMs).toBeLessThan(MAX_EVENT_LOOP_LAG_MS) + + // Why: didHitLimit must park the worktree in the huge-status state so + // background polling stops re-running tens-of-seconds git scans. + const hugeState = await orcaPage.evaluate( + (wId) => window.__store?.getState().gitStatusHugeByWorktree?.[wId] ?? null, + worktreeId + ) + expect(hugeState).not.toBeNull() + } finally { + await removeLargeFileCountRepo(fixture.repoPath) + } + }) + + test('untracked line-stat cache stays effective above 2,048 files', async ({ orcaPage }) => { + test.setTimeout(600_000) + // Why: the untracked line-stat cache historically capped at 2,048 entries + // with FIFO eviction, so a sequential scan over more files evicted every + // entry before the next poll revisited it (0% hit rate) and each 3s poll + // re-read every untracked file's contents. The cache is now LRU and sized + // to the status entry limit; this gate keeps it that way by comparing + // per-file warm rescan cost at two scales on the same machine — a healthy + // cache keeps the ratio near 1, thrash makes it several-fold. + const fileBytes = 65_536 + const smallRepo = createLargeFileCountRepo({ + trackedFiles: 10, + untrackedFiles: 2_000, + untrackedFileBytes: fileBytes + }) + let largeRepo: ReturnType | null = null + try { + largeRepo = createLargeFileCountRepo({ + trackedFiles: 10, + untrackedFiles: 4_000, + untrackedFileBytes: fileBytes + }) + await waitForSessionReady(orcaPage) + + const warmRescanPerFileMs = async (repoPath: string, files: number): Promise => { + const worktreeId = await addAndActivateRepo(orcaPage, repoPath) + const measurement = await measureSourceControlLoad(orcaPage, { + worktreeId, + repoPath, + expectedRows: files, + pollCycles: 1 + }) + return measurement.rescanMs / files + } + + const smallPerFileMs = await warmRescanPerFileMs(smallRepo.repoPath, 2_000) + const largePerFileMs = await warmRescanPerFileMs(largeRepo.repoPath, 4_000) + console.log( + `[large-file-count] line-stat-cache smallPerFileMs=${smallPerFileMs.toFixed(4)} largePerFileMs=${largePerFileMs.toFixed(4)} ratio=${(largePerFileMs / smallPerFileMs).toFixed(2)}` + ) + expect(largePerFileMs).toBeLessThan(smallPerFileMs * 2) + } finally { + await removeLargeFileCountRepo(smallRepo.repoPath) + if (largeRepo) { + await removeLargeFileCountRepo(largeRepo.repoPath) + } + } + }) + + test('a large clean repo (tracked files only) loads instantly', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(600_000) + const trackedFiles = Number(process.env.ORCA_LARGE_FILE_COUNT ?? '15000') + const fixture = createLargeFileCountRepo({ trackedFiles }) + try { + await waitForSessionReady(orcaPage) + const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) + const workingSetBeforeMb = await readRendererWorkingSetMb(electronApp) + const measurement = await measureSourceControlLoad(orcaPage, { + worktreeId, + repoPath: fixture.repoPath, + expectedRows: 0, + pollCycles: 2 + }) + const workingSetAfterMb = await readRendererWorkingSetMb(electronApp) + logMeasurement(`clean tracked=${trackedFiles}`, { + ...measurement, + rendererWorkingSetMb: { before: workingSetBeforeMb, after: workingSetAfterMb } + }) + + expect(measurement.entryCount).toBe(0) + expect(measurement.maxLagMs).toBeLessThan(MAX_EVENT_LOOP_LAG_MS) + } finally { + await removeLargeFileCountRepo(fixture.repoPath) + } + }) +}) From 4908a03671bcc2f30c85a8e773d62607a8e36ef3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:36:53 -0700 Subject: [PATCH 12/17] fix: dedupe fork-copied usage history across Claude, Codex, and OpenCode scanners (#8023) * fix: dedupe fork-copied usage history across Claude, Codex, and OpenCode scanners Coding-agent CLIs copy transcript/rollout history into new files on resume/fork, and the usage scanners deduped per-file only (or not at all), so copied history was re-counted once per descendant file (issue #8006: 38.6B tokens / $72,981 reported vs ~2.3B real). - claude-usage: cross-file turn ownership keyed on message.id:requestId; per-file ownedDedupeKeys persisted; deterministic sorted-path claim order; schema v3 -> v4 so inflated caches rebuild. - codex-usage: cross-file token_count event ownership keyed on the raw record identity (sessionId + timestamp + token tuples); fixes both the copied-prefix re-count and the total-only branch that re-counted the entire cumulative session per descendant rollout; legacy .orca-session-copies skip-bytes bridge unchanged; schema v3 -> v4. - opencode-usage: each sessionId is counted from exactly one database; the canonical opencode.db claims ahead of stale sibling copies (opencode-backup.db etc.) so backups no longer double totals, while backup-only sessions are still counted; schema v1 -> v2. Regression tests cover fork-copied files counted once (including the Codex total-only variant), duplicated OpenCode databases, and dedupe stability across cached incremental rescans. Co-authored-by: Orca * Fix cross-tool usage double-counting for fork/resume-copied history - Widen Claude dedupe keys with message-id and uuid fallbacks so forks missing requestId still dedupe correctly, and drop Codex's sessionId from event keys since fork/resume rewrites session_meta.id while copying identical token_count records. - Track hasDeferredClaims per cached file/database across Claude, Codex, and OpenCode scanners so that when an owning file is deleted, only files that deferred a claim need reparsing to reclaim those turns instead of rescanning the entire corpus. - Let OpenCode's live opencode.db reclaim sessions from a stale backup claim once it reappears, avoiding a frozen stale snapshot. - Bump schema versions to invalidate caches built with the old, narrower ownership keys (#8006, #8013 follow-up). --------- Co-authored-by: Orca --- src/main/claude-usage/scanner-scan.test.ts | 313 +++++++++++++++++++++ src/main/claude-usage/scanner.ts | 156 +++++++--- src/main/claude-usage/store.ts | 5 +- src/main/claude-usage/types.ts | 8 + src/main/codex-usage/scanner-paths.test.ts | 220 ++++++++++++++- src/main/codex-usage/scanner.test.ts | 5 + src/main/codex-usage/scanner.ts | 122 +++++++- src/main/codex-usage/store.test.ts | 6 +- src/main/codex-usage/store.ts | 5 +- src/main/codex-usage/types.ts | 12 + src/main/opencode-usage/scanner.test.ts | 213 +++++++++++++- src/main/opencode-usage/scanner.ts | 136 ++++++++- src/main/opencode-usage/store.test.ts | 10 +- src/main/opencode-usage/store.ts | 4 +- src/main/opencode-usage/types.ts | 7 + 15 files changed, 1148 insertions(+), 74 deletions(-) diff --git a/src/main/claude-usage/scanner-scan.test.ts b/src/main/claude-usage/scanner-scan.test.ts index eb657636cde..83fd6356d46 100644 --- a/src/main/claude-usage/scanner-scan.test.ts +++ b/src/main/claude-usage/scanner-scan.test.ts @@ -165,6 +165,319 @@ describe('scanClaudeUsageFiles', () => { expect(second.dailyAggregates[0]?.inputTokens).toBe(999) }) + it('dedupes fork copies when only message.id is present (no requestId)', async () => { + const root = await makeClaudeProjectsRoot() + const projectDir = join(root, '.claude', 'projects', 'project-a') + const originalFile = join(projectDir, 'aaaa-original.jsonl') + const forkFile = join(projectDir, 'bbbb-fork.jsonl') + + const turn = (sessionId: string, messageId: string, inputTokens: number): string => + JSON.stringify({ + type: 'assistant', + sessionId, + timestamp: '2026-04-09T10:00:00.000Z', + cwd: '/workspace/repo-a', + message: { + id: messageId, + model: 'claude-sonnet-4-6', + usage: { input_tokens: inputTokens, output_tokens: 10 } + } + }) + + await writeFile(originalFile, turn('session-1', 'msg_1', 100)) + await writeFile( + forkFile, + [turn('session-2', 'msg_1', 100), turn('session-2', 'msg_2', 50)].join('\n') + ) + + vi.resetModules() + vi.doMock('os', async () => ({ + ...(await vi.importActual('os')), + homedir: () => root + })) + const { scanClaudeUsageFiles } = await import('./scanner') + + const result = await scanClaudeUsageFiles([]) + expect(result.dailyAggregates.reduce((sum, row) => sum + row.inputTokens, 0)).toBe(150) + expect(result.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg:msg_1']) + expect(result.processedFiles[1]?.ownedDedupeKeys).toEqual(['msg:msg_2']) + }) + + it('dedupes fork copies via uuid when message ids are absent', async () => { + const root = await makeClaudeProjectsRoot() + const projectDir = join(root, '.claude', 'projects', 'project-a') + const originalFile = join(projectDir, 'aaaa-original.jsonl') + const forkFile = join(projectDir, 'bbbb-fork.jsonl') + + const turn = (sessionId: string, uuid: string, inputTokens: number): string => + JSON.stringify({ + type: 'assistant', + sessionId, + uuid, + timestamp: '2026-04-09T10:00:00.000Z', + cwd: '/workspace/repo-a', + message: { + model: 'claude-sonnet-4-6', + usage: { input_tokens: inputTokens, output_tokens: 10 } + } + }) + + await writeFile(originalFile, turn('session-1', 'uuid-1', 100)) + await writeFile( + forkFile, + [turn('session-2', 'uuid-1', 100), turn('session-2', 'uuid-2', 40)].join('\n') + ) + + vi.resetModules() + vi.doMock('os', async () => ({ + ...(await vi.importActual('os')), + homedir: () => root + })) + const { scanClaudeUsageFiles } = await import('./scanner') + + const result = await scanClaudeUsageFiles([]) + expect(result.dailyAggregates.reduce((sum, row) => sum + row.inputTokens, 0)).toBe(140) + expect(result.processedFiles[0]?.ownedDedupeKeys).toEqual(['uuid:uuid-1']) + expect(result.processedFiles[1]?.ownedDedupeKeys).toEqual(['uuid:uuid-2']) + }) + + it('counts turns copied into forked session files exactly once', async () => { + const root = await makeClaudeProjectsRoot() + const projectDir = join(root, '.claude', 'projects', 'project-a') + const originalFile = join(projectDir, 'aaaa-original.jsonl') + const forkFile = join(projectDir, 'bbbb-fork.jsonl') + + const turn = ( + sessionId: string, + messageId: string, + requestId: string, + inputTokens: number + ): string => + JSON.stringify({ + type: 'assistant', + sessionId, + timestamp: '2026-04-09T10:00:00.000Z', + requestId, + cwd: '/workspace/repo-a', + message: { + id: messageId, + model: 'claude-sonnet-4-6', + usage: { input_tokens: inputTokens, output_tokens: 10 } + } + }) + + await writeFile( + originalFile, + [turn('session-1', 'msg_1', 'req_1', 100), turn('session-1', 'msg_2', 'req_2', 200)].join( + '\n' + ) + ) + // Fork copies the original history with preserved message/request IDs but a + // rewritten sessionId, then appends its own new turn. + await writeFile( + forkFile, + [ + turn('session-2', 'msg_1', 'req_1', 100), + turn('session-2', 'msg_2', 'req_2', 200), + turn('session-2', 'msg_3', 'req_3', 400) + ].join('\n') + ) + + vi.resetModules() + vi.doMock('os', async () => ({ + ...(await vi.importActual('os')), + homedir: () => root + })) + const { scanClaudeUsageFiles } = await import('./scanner') + + const result = await scanClaudeUsageFiles([]) + const totalInput = result.dailyAggregates.reduce( + (sum, aggregate) => sum + aggregate.inputTokens, + 0 + ) + + expect(totalInput).toBe(700) + expect(result.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1', 'msg_2:req_2']) + expect(result.processedFiles[0]?.hasDeferredClaims).toBe(false) + expect(result.processedFiles[1]?.ownedDedupeKeys).toEqual(['msg_3:req_3']) + expect(result.processedFiles[1]?.hasDeferredClaims).toBe(true) + }) + + it('keeps fork dedupe stable when the original file projection is reused from cache', async () => { + const root = await makeClaudeProjectsRoot() + const projectDir = join(root, '.claude', 'projects', 'project-a') + const originalFile = join(projectDir, 'aaaa-original.jsonl') + const forkFile = join(projectDir, 'zzzz-fork.jsonl') + + const turn = ( + sessionId: string, + messageId: string, + requestId: string, + inputTokens: number + ): string => + JSON.stringify({ + type: 'assistant', + sessionId, + timestamp: '2026-04-09T10:00:00.000Z', + requestId, + cwd: '/workspace/repo-a', + message: { + id: messageId, + model: 'claude-sonnet-4-6', + usage: { input_tokens: inputTokens, output_tokens: 10 } + } + }) + + await writeFile(originalFile, turn('session-1', 'msg_1', 'req_1', 100)) + + vi.resetModules() + vi.doMock('os', async () => ({ + ...(await vi.importActual('os')), + homedir: () => root + })) + const { scanClaudeUsageFiles } = await import('./scanner') + + const first = await scanClaudeUsageFiles([]) + expect(first.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1']) + + // A fork appears later while the original stays unchanged (cache reuse). + await writeFile( + forkFile, + [turn('session-2', 'msg_1', 'req_1', 100), turn('session-2', 'msg_9', 'req_9', 50)].join('\n') + ) + + const second = await scanClaudeUsageFiles([], first.processedFiles) + const totalInput = second.dailyAggregates.reduce( + (sum, aggregate) => sum + aggregate.inputTokens, + 0 + ) + + expect(totalInput).toBe(150) + expect(second.processedFiles.map((file) => file.ownedDedupeKeys)).toEqual([ + ['msg_1:req_1'], + ['msg_9:req_9'] + ]) + + // Rescanning with the full cache stays stable. + const third = await scanClaudeUsageFiles([], second.processedFiles) + expect(third.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe( + 150 + ) + }) + + it('reclaims fork-copied turns when the owning original file is deleted', async () => { + const root = await makeClaudeProjectsRoot() + const projectDir = join(root, '.claude', 'projects', 'project-a') + const originalFile = join(projectDir, 'aaaa-original.jsonl') + const forkFile = join(projectDir, 'bbbb-fork.jsonl') + + const turn = ( + sessionId: string, + messageId: string, + requestId: string, + inputTokens: number + ): string => + JSON.stringify({ + type: 'assistant', + sessionId, + timestamp: '2026-04-09T10:00:00.000Z', + requestId, + cwd: '/workspace/repo-a', + message: { + id: messageId, + model: 'claude-sonnet-4-6', + usage: { input_tokens: inputTokens, output_tokens: 10 } + } + }) + + await writeFile(originalFile, turn('session-1', 'msg_1', 'req_1', 100)) + await writeFile( + forkFile, + [turn('session-2', 'msg_1', 'req_1', 100), turn('session-2', 'msg_9', 'req_9', 50)].join('\n') + ) + + vi.resetModules() + vi.doMock('os', async () => ({ + ...(await vi.importActual('os')), + homedir: () => root + })) + const { scanClaudeUsageFiles } = await import('./scanner') + + const first = await scanClaudeUsageFiles([]) + expect(first.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe( + 150 + ) + expect(first.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1']) + expect(first.processedFiles[1]?.ownedDedupeKeys).toEqual(['msg_9:req_9']) + + // Deleting the owner must reparse deferred forks so they can re-claim the + // copied turn instead of permanently under-counting. + await rm(originalFile) + + const second = await scanClaudeUsageFiles([], first.processedFiles) + expect(second.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe( + 150 + ) + expect(second.processedFiles).toHaveLength(1) + expect(second.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1', 'msg_9:req_9']) + }) + + it('keeps unrelated cached transcripts when a different owner file is deleted', async () => { + const root = await makeClaudeProjectsRoot() + const projectDir = join(root, '.claude', 'projects', 'project-a') + const originalFile = join(projectDir, 'aaaa-original.jsonl') + const forkFile = join(projectDir, 'bbbb-fork.jsonl') + const unrelatedFile = join(projectDir, 'cccc-unrelated.jsonl') + + const turn = ( + sessionId: string, + messageId: string, + requestId: string, + inputTokens: number + ): string => + JSON.stringify({ + type: 'assistant', + sessionId, + timestamp: '2026-04-09T10:00:00.000Z', + requestId, + cwd: '/workspace/repo-a', + message: { + id: messageId, + model: 'claude-sonnet-4-6', + usage: { input_tokens: inputTokens, output_tokens: 10 } + } + }) + + await writeFile(originalFile, turn('session-1', 'msg_1', 'req_1', 100)) + await writeFile( + forkFile, + [turn('session-2', 'msg_1', 'req_1', 100), turn('session-2', 'msg_9', 'req_9', 50)].join('\n') + ) + await writeFile(unrelatedFile, turn('session-3', 'msg_u', 'req_u', 25)) + + vi.resetModules() + vi.doMock('os', async () => ({ + ...(await vi.importActual('os')), + homedir: () => root + })) + const { scanClaudeUsageFiles } = await import('./scanner') + + const first = await scanClaudeUsageFiles([]) + const unrelatedBefore = first.processedFiles.find((file) => file.path === unrelatedFile) + expect(unrelatedBefore?.hasDeferredClaims).toBe(false) + expect(unrelatedBefore?.ownedDedupeKeys).toEqual(['msg_u:req_u']) + + await rm(originalFile) + + const second = await scanClaudeUsageFiles([], first.processedFiles) + const unrelatedAfter = second.processedFiles.find((file) => file.path === unrelatedFile) + // Unrelated files must keep their cached projection identity (stat-only path). + expect(unrelatedAfter).toBe(unrelatedBefore) + expect(second.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe( + 175 + ) + }) + it('canonicalizes repeated cwd paths once per scan file', async () => { const root = await makeClaudeProjectsRoot() const projectDir = join(root, '.claude', 'projects', 'project-a') diff --git a/src/main/claude-usage/scanner.ts b/src/main/claude-usage/scanner.ts index bac5d5cac5f..db1c1f21f7c 100644 --- a/src/main/claude-usage/scanner.ts +++ b/src/main/claude-usage/scanner.ts @@ -29,6 +29,8 @@ type ClaudeUsageSourceRecord = { cwd?: string gitBranch?: string requestId?: string + /** Stable row id when present; preserved across fork-copied history. */ + uuid?: string isSidechain?: boolean agentId?: string message?: { @@ -209,9 +211,11 @@ function stripClaudeSourceMetadata(turn: ClaudeUsageParsedSourceTurn): ClaudeUsa } } -function dedupeClaudeUsageTurns(turns: ClaudeUsageParsedSourceTurn[]): ClaudeUsageParsedTurn[] { +function dedupeClaudeUsageTurns( + turns: ClaudeUsageParsedSourceTurn[] +): ClaudeUsageParsedSourceTurn[] { const dedupeIndexByKey = new Map() - const deduped: ClaudeUsageParsedTurn[] = [] + const deduped: ClaudeUsageParsedSourceTurn[] = [] for (const turn of turns) { if (turn.dedupeKey) { @@ -228,8 +232,7 @@ function dedupeClaudeUsageTurns(turns: ClaudeUsageParsedSourceTurn[]): ClaudeUsa } } - const stripped = stripClaudeSourceMetadata(turn) - deduped.push(stripped) + deduped.push({ ...turn }) if (turn.dedupeKey) { dedupeIndexByKey.set(turn.dedupeKey, deduped.length - 1) } @@ -273,8 +276,10 @@ function parseClaudeUsageSourceRecord( model: parsed.message?.model ?? null, cwd: parsed.cwd ?? null, gitBranch: parsed.gitBranch ?? null, - dedupeKey: - parsed.message?.id && parsed.requestId ? `${parsed.message.id}:${parsed.requestId}` : null, + // Why: forks rewrite sessionId but keep message/request ids (and usually + // uuid). Prefer the strongest stable identity available so ownership still + // works when requestId is missing on older or partial rows. + dedupeKey: buildClaudeUsageDedupeKey(parsed), inputTokens, outputTokens, cacheReadTokens, @@ -282,6 +287,22 @@ function parseClaudeUsageSourceRecord( } } +function buildClaudeUsageDedupeKey(parsed: ClaudeUsageSourceRecord): string | null { + const messageId = parsed.message?.id?.trim() + const requestId = parsed.requestId?.trim() + if (messageId && requestId) { + return `${messageId}:${requestId}` + } + if (messageId) { + return `msg:${messageId}` + } + const uuid = parsed.uuid?.trim() + if (uuid) { + return `uuid:${uuid}` + } + return null +} + export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | null { const parsed = parseClaudeUsageSourceRecord(line) return parsed ? stripClaudeSourceMetadata(parsed) : null @@ -302,12 +323,12 @@ export async function parseClaudeUsageFile(filePath: string): Promise { const fileStat = await stat(filePath) let lineCount = 0 @@ -597,18 +618,6 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { } } -async function parseClaudeUsagePersistedFile( - filePath: string, - worktreeLookup: Map -): Promise { - const { processedFile, turns } = await readClaudeUsageScanFile(filePath) - const attributed = await attributeClaudeUsageTurns(turns, worktreeLookup) - return { - ...processedFile, - ...aggregateClaudeUsage(attributed) - } -} - export async function scanClaudeUsageFiles( worktrees: ClaudeUsageWorktreeRef[], previousProcessedFiles: ClaudeUsagePersistedFile[] = [] @@ -619,41 +628,122 @@ export async function scanClaudeUsageFiles( }> { const files = await listClaudeTranscriptFiles() const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file])) - const processedFiles: ClaudeUsagePersistedFile[] = [] const worktreeLookup = await buildWorktreeLookup(worktrees) - const sessionsById = new Map() - const dailyByKey = new Map() + const currentPaths = new Set(files) + // Why: when a file that owned dedupe keys is deleted, remaining forks still + // contain those turns but their caches record them as unowned. Only files + // that previously deferred claims can reclaim, so invalidate those — not the + // entire transcript corpus (histories can be gigabytes). + const lostOwnerPath = previousProcessedFiles.some( + (file) => + !currentPaths.has(file.path) && + Array.isArray(file.ownedDedupeKeys) && + file.ownedDedupeKeys.length > 0 + ) + + const reusedByPath = new Map() + const pathsToParse: string[] = [] for (let index = 0; index < files.length; index += FILE_SCAN_BATCH_SIZE) { const batch = files.slice(index, index + FILE_SCAN_BATCH_SIZE) - const results = await Promise.all( + const reusable = await Promise.all( batch.map(async (filePath) => { const fileInfo = await getProcessedFileStat(filePath) const previous = previousByPath.get(filePath) // Why: Claude histories can be gigabytes. Unchanged files should pay // only stat cost on refresh while preserving exactly the old projection. + // When an owner disappears, only deferred-claim files need reparse. + const mustReclaimDeferred = lostOwnerPath && previous?.hasDeferredClaims !== false const canReuse = + !mustReclaimDeferred && previous && previous.mtimeMs === fileInfo.mtimeMs && previous.size === fileInfo.size && Array.isArray(previous.sessions) && - Array.isArray(previous.dailyAggregates) - - return canReuse ? previous : parseClaudeUsagePersistedFile(filePath, worktreeLookup) + Array.isArray(previous.dailyAggregates) && + Array.isArray(previous.ownedDedupeKeys) && + typeof previous.hasDeferredClaims === 'boolean' + return canReuse ? previous : null }) ) - for (const processed of results) { - processedFiles.push(processed) - mergeClaudeSessions(sessionsById, processed.sessions) - mergeClaudeDailyAggregates(dailyByKey, processed.dailyAggregates) + for (const [batchIndex, previous] of reusable.entries()) { + if (previous) { + reusedByPath.set(batch[batchIndex], previous) + } else { + pathsToParse.push(batch[batchIndex]) + } } - // Why: transcript scans run in Electron's main process. Small parallel - // batches cut independent file I/O without letting Settings stay blocked. if (index + batch.length < files.length) { await yieldToEventLoop() } } + // Why: resuming or forking a Claude session copies earlier turns — with their + // original message/request IDs — into a new transcript file under a new + // session id. Per-file dedupe cannot see those copies, so long-lived sessions + // get re-counted on every fork (issue #8006). Cross-file ownership counts each + // turn for exactly one file; cached files keep the claims they persisted. + const turnOwnerByDedupeKey = new Map() + for (const [filePath, previous] of reusedByPath) { + for (const dedupeKey of previous.ownedDedupeKeys) { + // First cached claim wins so conflicting projections stay deterministic. + if (!turnOwnerByDedupeKey.has(dedupeKey)) { + turnOwnerByDedupeKey.set(dedupeKey, filePath) + } + } + } + + const parsedByPath = new Map() + for (let index = 0; index < pathsToParse.length; index += FILE_SCAN_BATCH_SIZE) { + const batch = pathsToParse.slice(index, index + FILE_SCAN_BATCH_SIZE) + // Why: transcript scans run in Electron's main process. Small parallel + // batches cut independent file I/O without letting Settings stay blocked. + const reads = await Promise.all(batch.map((filePath) => readClaudeUsageScanFile(filePath))) + for (const [batchIndex, filePath] of batch.entries()) { + const { processedFile, turns } = reads[batchIndex] + // Why: ownership claims must be sequential in sorted-path order so + // rescans assign duplicated turns to the same file deterministically. + const ownedTurns: ClaudeUsageParsedTurn[] = [] + const ownedDedupeKeys: string[] = [] + let hasDeferredClaims = false + for (const turn of turns) { + if (turn.dedupeKey) { + const owner = turnOwnerByDedupeKey.get(turn.dedupeKey) + if (owner !== undefined && owner !== filePath) { + hasDeferredClaims = true + continue + } + turnOwnerByDedupeKey.set(turn.dedupeKey, filePath) + ownedDedupeKeys.push(turn.dedupeKey) + } + ownedTurns.push(stripClaudeSourceMetadata(turn)) + } + const attributed = await attributeClaudeUsageTurns(ownedTurns, worktreeLookup) + parsedByPath.set(filePath, { + ...processedFile, + ...aggregateClaudeUsage(attributed), + ownedDedupeKeys, + hasDeferredClaims + }) + } + if (index + batch.length < pathsToParse.length) { + await yieldToEventLoop() + } + } + + const processedFiles: ClaudeUsagePersistedFile[] = [] + const sessionsById = new Map() + const dailyByKey = new Map() + for (const filePath of files) { + const processed = reusedByPath.get(filePath) ?? parsedByPath.get(filePath) + if (!processed) { + continue + } + processedFiles.push(processed) + mergeClaudeSessions(sessionsById, processed.sessions) + mergeClaudeDailyAggregates(dailyByKey, processed.dailyAggregates) + } + return { processedFiles, sessions: finalizeClaudeSessions(sessionsById), diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index 092ee6aa208..a020186ee90 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -19,7 +19,10 @@ import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-w import type { ClaudeUsagePersistedState } from './types' import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from './scanner' -const SCHEMA_VERSION = 3 +// Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older +// caches either lack ownership or used narrower keys and can under/over-count +// after fork reclaim (#8006). +const SCHEMA_VERSION = 5 const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 diff --git a/src/main/claude-usage/types.ts b/src/main/claude-usage/types.ts index 8760621d754..ec497e5e098 100644 --- a/src/main/claude-usage/types.ts +++ b/src/main/claude-usage/types.ts @@ -66,6 +66,14 @@ export type ClaudeUsagePersistedState = { export type ClaudeUsagePersistedFile = ClaudeUsageProcessedFile & { sessions: ClaudeUsageSession[] dailyAggregates: ClaudeUsageDailyAggregate[] + /** Dedupe keys (message.id:requestId) this file counted. Forked/resumed + * sessions copy earlier turns into new files; ownership keeps each turn + * counted by exactly one cached file across incremental scans. */ + ownedDedupeKeys: string[] + /** True when this file saw turns already claimed by another file. When that + * owner disappears, only deferred files need reparse to reclaim — not the + * entire transcript corpus. */ + hasDeferredClaims: boolean } export type ClaudeUsageParsedTurn = { diff --git a/src/main/codex-usage/scanner-paths.test.ts b/src/main/codex-usage/scanner-paths.test.ts index 46c10be0c8f..ecdc03e139f 100644 --- a/src/main/codex-usage/scanner-paths.test.ts +++ b/src/main/codex-usage/scanner-paths.test.ts @@ -1,5 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { linkSync, lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { + linkSync, + lstatSync, + mkdirSync, + mkdtempSync, + rmSync, + unlinkSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import type * as NodeOs from 'node:os' import { join } from 'node:path' @@ -300,6 +308,216 @@ describe('listCodexSessionFiles', () => { ).toBe(3) }) + it('counts token events copied into forked rollout files exactly once', async () => { + const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + mkdirSync(sessionsDir, { recursive: true }) + const originalPath = join(sessionsDir, 'aaaa-original.jsonl') + const forkPath = join(sessionsDir, 'bbbb-fork.jsonl') + const copiedPrefix = [ + `${JSON.stringify({ + type: 'session_meta', + payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') } + })}\n`, + usageRecord('2026-05-26T12:00:00.000Z', 10), + usageRecord('2026-05-26T12:01:00.000Z', 5, 15) + ].join('') + writeFileSync(originalPath, copiedPrefix, 'utf-8') + writeFileSync(forkPath, `${copiedPrefix}${usageRecord('2026-05-26T12:02:00.000Z', 7, 22)}`) + + const result = await scanCodexUsageFiles([], []) + + expect( + result.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0) + ).toBe(22) + expect( + result.dailyAggregates.reduce((total, aggregate) => total + aggregate.eventCount, 0) + ).toBe(3) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]?.totalTokens).toBe(22) + }) + + it('dedupes fork copies even when session_meta id is rewritten', async () => { + const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + mkdirSync(sessionsDir, { recursive: true }) + const originalPath = join(sessionsDir, 'aaaa-original.jsonl') + const forkPath = join(sessionsDir, 'bbbb-fork.jsonl') + const originalPrefix = [ + `${JSON.stringify({ + type: 'session_meta', + payload: { id: 'session-original', cwd: join(fakeHomeDir, 'repo') } + })}\n`, + usageRecord('2026-05-26T12:00:00.000Z', 10), + usageRecord('2026-05-26T12:01:00.000Z', 5, 15) + ].join('') + // Same token_count records, different session_meta id (real resume/fork shape). + const forkBody = [ + `${JSON.stringify({ + type: 'session_meta', + payload: { id: 'session-fork', cwd: join(fakeHomeDir, 'repo') } + })}\n`, + usageRecord('2026-05-26T12:00:00.000Z', 10), + usageRecord('2026-05-26T12:01:00.000Z', 5, 15), + usageRecord('2026-05-26T12:02:00.000Z', 7, 22) + ].join('') + writeFileSync(originalPath, originalPrefix, 'utf-8') + writeFileSync(forkPath, forkBody) + + const result = await scanCodexUsageFiles([], []) + expect( + result.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0) + ).toBe(22) + expect(result.processedFiles[0]?.ownedEventKeys).toHaveLength(2) + expect(result.processedFiles[1]?.ownedEventKeys).toHaveLength(1) + expect(result.processedFiles[1]?.hasDeferredClaims).toBe(true) + }) + + it('does not re-count the cumulative session for copied total-only records', async () => { + const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + mkdirSync(sessionsDir, { recursive: true }) + const originalPath = join(sessionsDir, 'aaaa-original.jsonl') + const forkPath = join(sessionsDir, 'bbbb-fork.jsonl') + const copiedPrefix = [ + `${JSON.stringify({ + type: 'session_meta', + payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') } + })}\n`, + totalOnlyUsageRecord('2026-05-26T12:00:00.000Z', 10), + totalOnlyUsageRecord('2026-05-26T12:01:00.000Z', 15) + ].join('') + writeFileSync(originalPath, copiedPrefix, 'utf-8') + // Without cross-file ownership the fork's first copied total-only record + // re-counts the entire cumulative session (10) as a fresh delta. + writeFileSync( + forkPath, + `${copiedPrefix}${totalOnlyUsageRecord('2026-05-26T12:02:00.000Z', 22)}` + ) + + const result = await scanCodexUsageFiles([], []) + + expect( + result.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0) + ).toBe(22) + expect( + result.dailyAggregates.reduce((total, aggregate) => total + aggregate.eventCount, 0) + ).toBe(3) + }) + + it('keeps fork dedupe stable when the original file is reused from cache', async () => { + const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + mkdirSync(sessionsDir, { recursive: true }) + const originalPath = join(sessionsDir, 'aaaa-original.jsonl') + const forkPath = join(sessionsDir, 'zzzz-fork.jsonl') + const copiedPrefix = [ + `${JSON.stringify({ + type: 'session_meta', + payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') } + })}\n`, + usageRecord('2026-05-26T12:00:00.000Z', 10), + usageRecord('2026-05-26T12:01:00.000Z', 5, 15) + ].join('') + writeFileSync(originalPath, copiedPrefix, 'utf-8') + + const first = await scanCodexUsageFiles([], []) + expect( + first.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0) + ).toBe(15) + expect(first.processedFiles[0]?.ownedEventKeys).toHaveLength(2) + + // A fork appears later while the original stays unchanged (cache reuse). + writeFileSync(forkPath, `${copiedPrefix}${usageRecord('2026-05-26T12:02:00.000Z', 7, 22)}`) + + const second = await scanCodexUsageFiles([], first.processedFiles) + expect( + second.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0) + ).toBe(22) + expect(second.processedFiles.map((file) => file.ownedEventKeys.length)).toEqual([2, 1]) + + // Rescanning with the full cache stays stable. + const third = await scanCodexUsageFiles([], second.processedFiles) + expect( + third.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0) + ).toBe(22) + }) + + it('reclaims copied token events when the owning original file is deleted', async () => { + const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + mkdirSync(sessionsDir, { recursive: true }) + const originalPath = join(sessionsDir, 'aaaa-original.jsonl') + const forkPath = join(sessionsDir, 'bbbb-fork.jsonl') + const copiedPrefix = [ + `${JSON.stringify({ + type: 'session_meta', + payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') } + })}\n`, + usageRecord('2026-05-26T12:00:00.000Z', 10), + usageRecord('2026-05-26T12:01:00.000Z', 5, 15) + ].join('') + writeFileSync(originalPath, copiedPrefix, 'utf-8') + writeFileSync(forkPath, `${copiedPrefix}${usageRecord('2026-05-26T12:02:00.000Z', 7, 22)}`) + + const first = await scanCodexUsageFiles([], []) + expect( + first.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0) + ).toBe(22) + expect(first.processedFiles[0]?.ownedEventKeys).toHaveLength(2) + expect(first.processedFiles[0]?.hasDeferredClaims).toBe(false) + expect(first.processedFiles[1]?.ownedEventKeys).toHaveLength(1) + expect(first.processedFiles[1]?.hasDeferredClaims).toBe(true) + + // Deleting the owner must reparse deferred forks so they can re-claim the + // copied prefix instead of permanently under-counting. + unlinkSync(originalPath) + + const second = await scanCodexUsageFiles([], first.processedFiles) + expect( + second.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0) + ).toBe(22) + expect(second.processedFiles).toHaveLength(1) + expect(second.processedFiles[0]?.ownedEventKeys).toHaveLength(3) + }) + + it('keeps unrelated cached rollouts when a different owner file is deleted', async () => { + const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + mkdirSync(sessionsDir, { recursive: true }) + const originalPath = join(sessionsDir, 'aaaa-original.jsonl') + const forkPath = join(sessionsDir, 'bbbb-fork.jsonl') + const unrelatedPath = join(sessionsDir, 'cccc-unrelated.jsonl') + const copiedPrefix = [ + `${JSON.stringify({ + type: 'session_meta', + payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') } + })}\n`, + usageRecord('2026-05-26T12:00:00.000Z', 10), + usageRecord('2026-05-26T12:01:00.000Z', 5, 15) + ].join('') + writeFileSync(originalPath, copiedPrefix, 'utf-8') + writeFileSync(forkPath, `${copiedPrefix}${usageRecord('2026-05-26T12:02:00.000Z', 7, 22)}`) + writeFileSync( + unrelatedPath, + [ + `${JSON.stringify({ + type: 'session_meta', + payload: { id: 'session-9', cwd: join(fakeHomeDir, 'repo') } + })}\n`, + usageRecord('2026-05-26T13:00:00.000Z', 3) + ].join('') + ) + + const first = await scanCodexUsageFiles([], []) + const unrelatedBefore = first.processedFiles.find((file) => file.path === unrelatedPath) + expect(unrelatedBefore?.hasDeferredClaims).toBe(false) + expect(unrelatedBefore?.ownedEventKeys).toHaveLength(1) + + unlinkSync(originalPath) + + const second = await scanCodexUsageFiles([], first.processedFiles) + const unrelatedAfter = second.processedFiles.find((file) => file.path === unrelatedPath) + expect(unrelatedAfter).toBe(unrelatedBefore) + expect( + second.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0) + ).toBe(25) + }) + it('treats a leading total-only source suffix record as baseline', async () => { const runtimeSessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') const runtimeBridgeMarkerDir = join( diff --git a/src/main/codex-usage/scanner.test.ts b/src/main/codex-usage/scanner.test.ts index 62777257b06..6426222461f 100644 --- a/src/main/codex-usage/scanner.test.ts +++ b/src/main/codex-usage/scanner.test.ts @@ -99,6 +99,7 @@ describe('parseCodexUsageRecord', () => { expect(first).toEqual({ sessionId: 'session-1', timestamp: '2026-04-09T10:00:00.000Z', + eventKey: expect.any(String), cwd: '/workspace/repo/packages/app', model: 'gpt-5.2-codex', hasInferredPricing: false, @@ -196,6 +197,7 @@ describe('attributeCodexUsageEvent', () => { { sessionId: 'session-1', timestamp: '2026-04-09T10:00:00.000Z', + eventKey: 'event-1', cwd: '/workspace/repo/app2/subdir', model: 'gpt-5.2-codex', hasInferredPricing: false, @@ -233,6 +235,7 @@ describe('attributeCodexUsageEvent', () => { { sessionId: 'session-1', timestamp: '2026-04-09T10:00:00.000Z', + eventKey: 'event-1', cwd: '/workspace/repo/..fixtures/session', model: 'gpt-5.2-codex', hasInferredPricing: false, @@ -263,6 +266,7 @@ describe('attributeCodexUsageEvent', () => { { sessionId: 'session-1', timestamp: '2026-04-09T10:00:00.000Z', + eventKey: 'event-1', cwd: '/workspace/repo/../other/session', model: 'gpt-5.2-codex', hasInferredPricing: false, @@ -292,6 +296,7 @@ describe('attributeCodexUsageEvent', () => { { sessionId: 'session-1', timestamp: '2026-04-09T10:00:00.000Z', + eventKey: 'event-1', cwd: 'D:\\other\\repo', model: 'gpt-5.2-codex', hasInferredPricing: false, diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index a9764b53bb7..97e8f6f7b09 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -388,6 +388,28 @@ function resolveCodexUsageDelta( return null } +function buildCodexUsageEventKey( + timestamp: string, + totalUsage: CodexUsageRawUsage | null, + lastUsage: CodexUsageRawUsage | null +): string { + // Why: fork/resume copies token_count records byte-for-byte into a new + // rollout file, but session_meta.id is often rewritten to the new session. + // Key only on the raw record fields (timestamp + usage tuples) so the copy + // matches the original regardless of surrounding parse context / session id. + const tupleOf = (usage: CodexUsageRawUsage | null): string => + usage + ? [ + usage.inputTokens, + usage.cachedInputTokens, + usage.outputTokens, + usage.reasoningOutputTokens, + usage.totalTokens + ].join(',') + : '' + return [timestamp, tupleOf(totalUsage), tupleOf(lastUsage)].join('|') +} + function extractString(value: unknown): string | null { if (typeof value !== 'string') { return null @@ -956,6 +978,7 @@ export function parseCodexUsageRecord( return { sessionId: context.sessionId, timestamp: parsed.timestamp, + eventKey: buildCodexUsageEventKey(parsed.timestamp, totalUsage, lastUsage), cwd: context.currentCwd ?? context.sessionCwd, model: resolvedModel, hasInferredPricing, @@ -970,7 +993,7 @@ export function parseCodexUsageRecord( export async function parseCodexUsageFile( filePath: string, worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[], - options: { skipInitialBytes?: number } = {} + options: { skipInitialBytes?: number; claimEventKey?: (eventKey: string) => boolean } = {} ): Promise { const processedFile = await getProcessedFileInfo(filePath) const lines = createInterface({ @@ -992,11 +1015,21 @@ export async function parseCodexUsageFile( totalOnlyBaselinePending: (options.skipInitialBytes ?? 0) > 0 } + const ownedEventKeys = new Set() + let hasDeferredClaims = false for await (const line of lines) { const parsed = parseCodexUsageRecord(line, context) if (!parsed) { continue } + // Why: fork/resume rollouts start with a copied prefix of the parent file. + // Events another file already owns are dropped here, but the record still + // advanced context.previousTotals above, so later deltas stay correct. + if (options.claimEventKey && !options.claimEventKey(parsed.eventKey)) { + hasDeferredClaims = true + continue + } + ownedEventKeys.add(parsed.eventKey) const attributed = await attributeCodexUsageEvent(parsed, worktrees) if (attributed) { events.push(attributed) @@ -1005,7 +1038,9 @@ export async function parseCodexUsageFile( return { ...processedFile, - ...aggregateCodexUsage(events) + ...aggregateCodexUsage(events), + ownedEventKeys: [...ownedEventKeys], + hasDeferredClaims } } @@ -1019,31 +1054,77 @@ export async function scanCodexUsageFiles( }> { const files = await listCodexSessionFiles() const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file])) - const processedFiles: CodexUsagePersistedFile[] = [] const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees) const legacySourceSkipBytesByPath = getLegacySourceSkipBytesByPath(files) - const sessionsById = new Map() - const dailyByKey = new Map() + const currentPaths = new Set(files) + // Why: when a rollout that owned event keys is deleted, remaining forks still + // contain those records but their caches record them as unowned. Only files + // that previously deferred claims can reclaim, so invalidate those — not the + // entire rollout corpus. + const lostOwnerPath = previousProcessedFiles.some( + (file) => + !currentPaths.has(file.path) && + Array.isArray(file.ownedEventKeys) && + file.ownedEventKeys.length > 0 + ) + + const reusedByPath = new Map() + const pathsToParse: string[] = [] for (const [index, filePath] of files.entries()) { const legacySourceSkipBytes = legacySourceSkipBytesByPath.get(filePath) ?? 0 const fileInfo = await getProcessedFileInfo(filePath) const previous = previousByPath.get(filePath) + // When an owner disappears, only deferred-claim files need reparse. + const mustReclaimDeferred = lostOwnerPath && previous?.hasDeferredClaims !== false const canReuse = + !mustReclaimDeferred && legacySourceSkipBytes === 0 && previous && previous.mtimeMs === fileInfo.mtimeMs && - previous.size === fileInfo.size + previous.size === fileInfo.size && + Array.isArray(previous.ownedEventKeys) && + typeof previous.hasDeferredClaims === 'boolean' + if (canReuse) { + reusedByPath.set(filePath, previous) + } else { + pathsToParse.push(filePath) + } + if ((index + 1) % YIELD_EVERY_FILES === 0) { + await yieldToEventLoop() + } + } - const processed = canReuse - ? previous - : await parseCodexUsageFile(filePath, worktreesWithCanonicalPaths, { - skipInitialBytes: legacySourceSkipBytes - }) + // Why: resuming or forking a Codex session copies the parent rollout's + // token_count records into a new file, so per-file parsing re-counts the + // whole copied history once per descendant (#8006). Cross-file ownership + // counts each record for exactly one file; cached files keep the claims + // they persisted, and new files claim in sorted-path order so rescans stay + // deterministic. + const eventOwnerByKey = new Map() + for (const [filePath, previous] of reusedByPath) { + for (const eventKey of previous.ownedEventKeys) { + // First cached claim wins so conflicting projections stay deterministic. + if (!eventOwnerByKey.has(eventKey)) { + eventOwnerByKey.set(eventKey, filePath) + } + } + } - processedFiles.push(processed) - mergeSessions(sessionsById, processed.sessions) - mergeDailyAggregates(dailyByKey, processed.dailyAggregates) + const parsedByPath = new Map() + for (const [index, filePath] of pathsToParse.entries()) { + const processed = await parseCodexUsageFile(filePath, worktreesWithCanonicalPaths, { + skipInitialBytes: legacySourceSkipBytesByPath.get(filePath) ?? 0, + claimEventKey: (eventKey) => { + const owner = eventOwnerByKey.get(eventKey) + if (owner !== undefined && owner !== filePath) { + return false + } + eventOwnerByKey.set(eventKey, filePath) + return true + } + }) + parsedByPath.set(filePath, processed) // Why: Codex session history can grow large, and scans run on the Electron // main process. Yield regularly so opening Settings does not stall while @@ -1053,6 +1134,19 @@ export async function scanCodexUsageFiles( } } + const processedFiles: CodexUsagePersistedFile[] = [] + const sessionsById = new Map() + const dailyByKey = new Map() + for (const filePath of files) { + const processed = reusedByPath.get(filePath) ?? parsedByPath.get(filePath) + if (!processed) { + continue + } + processedFiles.push(processed) + mergeSessions(sessionsById, processed.sessions) + mergeDailyAggregates(dailyByKey, processed.dailyAggregates) + } + return { processedFiles, sessions: finalizeSessions(sessionsById), diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 1bb3732373b..055af8f3a23 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -111,7 +111,7 @@ describe('CodexUsageStore', () => { it('persists a successful refresh with one compact disk write', async () => { const store = createStoreWithState({ - schemaVersion: 3, + schemaVersion: 5, scanState: { enabled: true, lastScanStartedAt: null, @@ -138,7 +138,7 @@ describe('CodexUsageStore', () => { const pendingScan = createDeferred() vi.mocked(scanCodexUsageFiles).mockReturnValueOnce(pendingScan.promise) const store = createStoreWithState({ - schemaVersion: 3, + schemaVersion: 5, scanState: { enabled: true, lastScanStartedAt: null, @@ -768,7 +768,7 @@ describe('CodexUsageStore', () => { } as unknown as CodexUsagePersistedState) expect(normalized).toEqual({ - schemaVersion: 3, + schemaVersion: 5, worktreeFingerprint: null, processedFiles: [], sessions: [], diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index e2963245ca9..ca42ba34061 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -19,7 +19,10 @@ import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-w import type { CodexUsagePersistedState } from './types' import { createWorktreeRefs, scanCodexUsageFiles } from './scanner' -const SCHEMA_VERSION = 3 +// Why: v5 keys Codex ownership on raw token_count identity without session id +// so forks that rewrite session_meta still match. Older caches used session- +// scoped keys and can double-count after fork/resume (#8006). +const SCHEMA_VERSION = 5 const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 diff --git a/src/main/codex-usage/types.ts b/src/main/codex-usage/types.ts index 4b54c6f6cee..d7e6f058b45 100644 --- a/src/main/codex-usage/types.ts +++ b/src/main/codex-usage/types.ts @@ -86,6 +86,14 @@ export type CodexUsageDailyAggregate = { export type CodexUsagePersistedFile = CodexUsageProcessedFile & { sessions: CodexUsageSession[] dailyAggregates: CodexUsageDailyAggregate[] + /** Event keys this file counted. Resumed/forked rollouts copy earlier + * token_count records into new files; ownership keeps each record counted + * by exactly one cached file across incremental scans. */ + ownedEventKeys: string[] + /** True when this file saw events already claimed by another file. When that + * owner disappears, only deferred files need reparse to reclaim — not the + * entire rollout corpus. */ + hasDeferredClaims: boolean } export type CodexUsagePersistedState = { @@ -105,6 +113,10 @@ export type CodexUsagePersistedState = { export type CodexUsageParsedEvent = { sessionId: string timestamp: string + /** Raw-record identity (timestamp + token tuples) used to dedupe the same + * token_count record copied across fork/resume rollout files. Session id is + * omitted because forks rewrite session_meta while copying the record. */ + eventKey: string model: string | null cwd: string | null hasInferredPricing: boolean diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts index c4bd46aae68..fbdc56bb5d5 100644 --- a/src/main/opencode-usage/scanner.test.ts +++ b/src/main/opencode-usage/scanner.test.ts @@ -1,12 +1,13 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import Database from '../sqlite/sync-database' import { attributeOpenCodeUsageEvent, parseOpenCodeUsageDatabase, - parseOpenCodeUsageRow + parseOpenCodeUsageRow, + scanOpenCodeUsageDatabases } from './scanner' const WORKTREE = '/workspace/repo' @@ -32,6 +33,50 @@ function worktrees() { ] } +function createSessionTotalsSchema(db: Database.Database): void { + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT, + title TEXT, + model TEXT, + cost REAL, + tokens_input INTEGER, + tokens_output INTEGER, + tokens_reasoning INTEGER, + tokens_cache_read INTEGER, + time_created INTEGER, + time_updated INTEGER + ); + `) +} + +function insertSessionTotalsRow( + db: Database.Database, + sessionId: string, + inputTokens: number +): void { + db.prepare( + `INSERT INTO session ( + id, directory, title, model, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, + time_created, time_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + sessionId, + `${WORKTREE}/packages/app`, + 'Session', + JSON.stringify({ providerID: 'anthropic', id: 'claude-sonnet-4-5' }), + 0.01, + inputTokens, + 100, + 0, + 0, + 1_777_777_700_000, + 1_777_777_800_000 + ) +} + function usageEvent(cwd: string) { return { sessionId: 'session-1', @@ -259,6 +304,17 @@ describe('parseOpenCodeUsageDatabase', () => { }) }) + it('reports the session ids the database counted', async () => { + const { db, path } = createTempDb() + createSessionTotalsSchema(db) + insertSessionTotalsRow(db, 'session-1', 1000) + db.close() + + const parsed = await parseOpenCodeUsageDatabase(path, worktrees()) + + expect(parsed.ownedSessionIds).toEqual(['session-1']) + }) + it('prefers session_message rows over legacy message rows to avoid double counting', async () => { const { db, path } = createTempDb() db.exec(` @@ -324,3 +380,154 @@ describe('parseOpenCodeUsageDatabase', () => { expect(parsed.sessions[0]?.eventCount).toBe(1) }) }) + +describe('scanOpenCodeUsageDatabases', () => { + let dataRoot: string + let openCodeDir: string + let previousXdgDataHome: string | undefined + let previousOpenCodeDb: string | undefined + + beforeEach(() => { + dataRoot = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-scan-')) + openCodeDir = join(dataRoot, 'opencode') + mkdirSync(openCodeDir, { recursive: true }) + previousXdgDataHome = process.env.XDG_DATA_HOME + previousOpenCodeDb = process.env.OPENCODE_DB + process.env.XDG_DATA_HOME = dataRoot + delete process.env.OPENCODE_DB + }) + + afterEach(() => { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome + } + if (previousOpenCodeDb === undefined) { + delete process.env.OPENCODE_DB + } else { + process.env.OPENCODE_DB = previousOpenCodeDb + } + rmSync(dataRoot, { recursive: true, force: true }) + }) + + function writeSessionTotalsDb(fileName: string, rows: [string, number][]): string { + const path = join(openCodeDir, fileName) + const db = new Database(path) + createSessionTotalsSchema(db) + for (const [sessionId, inputTokens] of rows) { + insertSessionTotalsRow(db, sessionId, inputTokens) + } + db.close() + return path + } + + it('counts a session duplicated into a stale backup database exactly once', async () => { + // The backup holds a stale snapshot of session-1; the canonical db has + // grown since. The canonical totals must win and be counted once. + writeSessionTotalsDb('opencode.db', [ + ['session-1', 1000], + ['session-2', 300] + ]) + writeSessionTotalsDb('opencode-backup.db', [['session-1', 400]]) + + const result = await scanOpenCodeUsageDatabases([], []) + + expect(result.sessions).toHaveLength(2) + const sessionOne = result.sessions.find((session) => session.sessionId === 'session-1') + expect(sessionOne?.totalInputTokens).toBe(1000) + expect(sessionOne?.eventCount).toBe(1) + expect( + result.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0) + ).toBe(1300) + }) + + it('still counts backup-only sessions and stays stable across cached rescans', async () => { + const canonicalPath = writeSessionTotalsDb('opencode.db', [['session-1', 1000]]) + // The backup duplicates session-1 (stale) and preserves session-9, which + // no longer exists in the canonical database. + writeSessionTotalsDb('opencode-backup.db', [ + ['session-1', 400], + ['session-9', 50] + ]) + + const first = await scanOpenCodeUsageDatabases([], []) + expect( + first.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0) + ).toBe(1050) + + const second = await scanOpenCodeUsageDatabases([], first.processedDatabases) + expect( + second.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0) + ).toBe(1050) + + // The canonical db keeps growing while the backup stays cached; session-1 + // must stay owned by the canonical db. + const db = new Database(canonicalPath) + insertSessionTotalsRow(db, 'session-2', 200) + db.close() + + const third = await scanOpenCodeUsageDatabases([], second.processedDatabases) + expect( + third.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0) + ).toBe(1250) + const sessionOne = third.sessions.find((session) => session.sessionId === 'session-1') + expect(sessionOne?.totalInputTokens).toBe(1000) + expect(sessionOne?.eventCount).toBe(1) + }) + + it('lets the live database reclaim sessions after a sticky backup-only claim', async () => { + // First scan only has the backup (e.g. live db temporarily missing), so it + // owns session-1 at the stale snapshot. When opencode.db reappears with + // higher totals it must reclaim the session instead of staying frozen. + writeSessionTotalsDb('opencode-backup.db', [['session-1', 400]]) + + const first = await scanOpenCodeUsageDatabases([], []) + expect( + first.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0) + ).toBe(400) + expect(first.processedDatabases[0]?.ownedSessionIds).toEqual(['session-1']) + + writeSessionTotalsDb('opencode.db', [ + ['session-1', 1000], + ['session-2', 200] + ]) + + const second = await scanOpenCodeUsageDatabases([], first.processedDatabases) + expect( + second.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0) + ).toBe(1200) + const sessionOne = second.sessions.find((session) => session.sessionId === 'session-1') + expect(sessionOne?.totalInputTokens).toBe(1000) + expect(sessionOne?.eventCount).toBe(1) + }) + + it('reclaims sessions when the owning live database is deleted', async () => { + const canonicalPath = writeSessionTotalsDb('opencode.db', [ + ['session-1', 1000], + ['session-2', 200] + ]) + writeSessionTotalsDb('opencode-backup.db', [ + ['session-1', 400], + ['session-9', 50] + ]) + + const first = await scanOpenCodeUsageDatabases([], []) + expect( + first.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0) + ).toBe(1250) + + unlinkSync(canonicalPath) + + const second = await scanOpenCodeUsageDatabases([], first.processedDatabases) + expect( + second.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0) + ).toBe(450) + expect(second.sessions.map((session) => session.sessionId).sort()).toEqual([ + 'session-1', + 'session-9' + ]) + const sessionOne = second.sessions.find((session) => session.sessionId === 'session-1') + expect(sessionOne?.totalInputTokens).toBe(400) + }) +}) diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 8a61886886f..4e2a3963597 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs' import { readdir, realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' -import { isAbsolute, join, posix, win32 } from 'node:path' +import { basename, isAbsolute, join, posix, win32 } from 'node:path' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import Database from '../sqlite/sync-database' @@ -121,6 +121,18 @@ export async function listOpenCodeDatabases(): Promise { } } +function compareOpenCodeClaimPriority(left: string, right: string): number { + // Why: the canonical opencode.db is the live database; it must claim + // duplicated sessions ahead of stale sibling copies. Remaining ties use + // path order so ownership is deterministic across rescans. + const leftRank = basename(left).toLowerCase() === 'opencode.db' ? 0 : 1 + const rightRank = basename(right).toLowerCase() === 'opencode.db' ? 0 : 1 + if (leftRank !== rightRank) { + return leftRank - rightRank + } + return left < right ? -1 : left > right ? 1 : 0 +} + export async function getProcessedDatabaseInfo( dbPath: string ): Promise { @@ -831,18 +843,32 @@ function mergeDailyAggregates( export async function parseOpenCodeUsageDatabase( dbPath: string, - worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[], + options: { claimSession?: (sessionId: string) => boolean } = {} ): Promise { const processedDatabase = await getProcessedDatabaseInfo(dbPath) const db = new Database(dbPath, { readonly: true, fileMustExist: true }) try { db.pragma('query_only = ON') const events: OpenCodeUsageAttributedEvent[] = [] + const claimedBySessionId = new Map() + let hasDeferredClaims = false for (const row of selectUsageRows(db)) { const parsed = parseOpenCodeUsageRow(row) if (!parsed) { continue } + // Why: a stale sibling copy of opencode.db carries the same sessions, so + // each session must be counted from exactly one database (#8006). + let owned = claimedBySessionId.get(parsed.sessionId) + if (owned === undefined) { + owned = options.claimSession ? options.claimSession(parsed.sessionId) : true + claimedBySessionId.set(parsed.sessionId, owned) + } + if (!owned) { + hasDeferredClaims = true + continue + } const attributed = await attributeOpenCodeUsageEvent(parsed, worktrees) if (attributed) { events.push(attributed) @@ -850,7 +876,11 @@ export async function parseOpenCodeUsageDatabase( } return { ...processedDatabase, - ...aggregateOpenCodeUsage(events) + ...aggregateOpenCodeUsage(events), + ownedSessionIds: [...claimedBySessionId.entries()] + .filter(([, owned]) => owned) + .map(([sessionId]) => sessionId), + hasDeferredClaims } } finally { db.close() @@ -869,29 +899,107 @@ export async function scanOpenCodeUsageDatabases( const previousByPath = new Map( previousProcessedDatabases.map((database) => [database.path, database]) ) - const processedDatabases: OpenCodeUsagePersistedDatabase[] = [] const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees) - const sessionsById = new Map() - const dailyByKey = new Map() - for (const [index, dbPath] of dbPaths.entries()) { + const currentPaths = new Set(dbPaths) + // Why: when a database that owned sessions is deleted, remaining siblings + // still contain those sessions but their caches record them as unowned. + // Only databases that previously deferred claims can reclaim. + const lostOwnerPath = previousProcessedDatabases.some( + (database) => + !currentPaths.has(database.path) && + Array.isArray(database.ownedSessionIds) && + database.ownedSessionIds.length > 0 + ) + + const reusedByPath = new Map() + const pathsToParse: string[] = [] + for (const dbPath of dbPaths) { const databaseInfo = await getProcessedDatabaseInfo(dbPath) const previous = previousByPath.get(dbPath) + // When an owner disappears, only deferred-claim databases need reparse. + const mustReclaimDeferred = lostOwnerPath && previous?.hasDeferredClaims !== false const canReuse = - previous && previous.mtimeMs === databaseInfo.mtimeMs && previous.size === databaseInfo.size - const processed = canReuse - ? previous - : await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths) + !mustReclaimDeferred && + previous && + previous.mtimeMs === databaseInfo.mtimeMs && + previous.size === databaseInfo.size && + Array.isArray(previous.ownedSessionIds) && + typeof previous.hasDeferredClaims === 'boolean' + if (canReuse) { + reusedByPath.set(dbPath, previous) + } else { + pathsToParse.push(dbPath) + } + } - processedDatabases.push(processed) - mergeSessions(sessionsById, processed.sessions) - mergeDailyAggregates(dailyByKey, processed.dailyAggregates) + // Why: a sticky backup claim from a scan where opencode.db was missing would + // otherwise freeze a still-growing session at the backup snapshot when the + // live db reappears. Reparse lower-priority siblings whenever a higher- + // priority path is being re-evaluated so the live db can reclaim sessions + // without double-counting cached backup aggregates. + const demotedReusePaths: string[] = [] + for (const dbPath of reusedByPath.keys()) { + const higherPriorityParsing = pathsToParse.some( + (candidate) => compareOpenCodeClaimPriority(candidate, dbPath) < 0 + ) + if (higherPriorityParsing) { + demotedReusePaths.push(dbPath) + } + } + for (const dbPath of demotedReusePaths) { + reusedByPath.delete(dbPath) + pathsToParse.push(dbPath) + } + + // Why: `opencode-*.db` siblings are typically stale copies of `opencode.db` + // (backups), so mergeSessions would double every duplicated session (#8006). + // Each session is counted from exactly one database. The canonical live db + // claims first so a stale backup cannot freeze a still-growing session at + // its snapshot totals; cached databases keep the claims they persisted. + const sessionOwnerById = new Map() + for (const dbPath of [...reusedByPath.keys()].sort(compareOpenCodeClaimPriority)) { + const previous = reusedByPath.get(dbPath) + for (const sessionId of previous?.ownedSessionIds ?? []) { + if (!sessionOwnerById.has(sessionId)) { + sessionOwnerById.set(sessionId, dbPath) + } + } + } + + const parsedByPath = new Map() + const orderedPathsToParse = [...pathsToParse].sort(compareOpenCodeClaimPriority) + for (const [index, dbPath] of orderedPathsToParse.entries()) { + const processed = await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths, { + claimSession: (sessionId) => { + const owner = sessionOwnerById.get(sessionId) + if (owner !== undefined && owner !== dbPath) { + return false + } + sessionOwnerById.set(sessionId, dbPath) + return true + } + }) + parsedByPath.set(dbPath, processed) if ((index + 1) % YIELD_EVERY_DATABASES === 0) { await yieldToEventLoop() } } + const processedDatabases: OpenCodeUsagePersistedDatabase[] = [] + const sessionsById = new Map() + const dailyByKey = new Map() + for (const dbPath of dbPaths) { + const processed = reusedByPath.get(dbPath) ?? parsedByPath.get(dbPath) + if (!processed) { + continue + } + processedDatabases.push(processed) + mergeSessions(sessionsById, processed.sessions) + mergeDailyAggregates(dailyByKey, processed.dailyAggregates) + } + return { processedDatabases, sessions: finalizeSessions(sessionsById), diff --git a/src/main/opencode-usage/store.test.ts b/src/main/opencode-usage/store.test.ts index bca001390c2..aea35628f60 100644 --- a/src/main/opencode-usage/store.test.ts +++ b/src/main/opencode-usage/store.test.ts @@ -19,7 +19,7 @@ import { OpenCodeUsageStore, normalizePersistedState } from './store' function getDefaultState(): OpenCodeUsagePersistedState { return { - schemaVersion: 1, + schemaVersion: 2, worktreeFingerprint: null, processedDatabases: [], sessions: [], @@ -283,7 +283,9 @@ describe('OpenCodeUsageStore', () => { mtimeMs: 1, size: 2, sessions: [makeSession()], - dailyAggregates: [makeDaily()] + dailyAggregates: [makeDaily()], + ownedSessionIds: ['session-1'], + hasDeferredClaims: false } ], sessions: [makeSession()], @@ -300,7 +302,9 @@ describe('OpenCodeUsageStore', () => { mtimeMs: 1, size: 2, sessions: [], - dailyAggregates: [] + dailyAggregates: [], + ownedSessionIds: [], + hasDeferredClaims: false } ] }).processedDatabases diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts index abfe2ca740a..b926f32651c 100644 --- a/src/main/opencode-usage/store.ts +++ b/src/main/opencode-usage/store.ts @@ -18,7 +18,9 @@ import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-w import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' import { createWorktreeRefs, scanOpenCodeUsageDatabases } from './scanner' -const SCHEMA_VERSION = 1 +// Why: v2 adds per-database session ownership (stale sibling-copy dedupe). +// Older caches were built without it and can carry doubled sessions (#8006). +const SCHEMA_VERSION = 2 const STALE_MS = 5 * 60_000 let _openCodeUsageFile: string | null = null diff --git a/src/main/opencode-usage/types.ts b/src/main/opencode-usage/types.ts index ed7f3d2143c..cf8d2c6a8d0 100644 --- a/src/main/opencode-usage/types.ts +++ b/src/main/opencode-usage/types.ts @@ -86,6 +86,13 @@ export type OpenCodeUsageDailyAggregate = { export type OpenCodeUsagePersistedDatabase = OpenCodeUsageProcessedDatabase & { sessions: OpenCodeUsageSession[] dailyAggregates: OpenCodeUsageDailyAggregate[] + /** Session ids this database counted. Sibling copies (opencode-backup.db) + * duplicate sessions; ownership keeps each session counted by exactly one + * cached database across incremental scans. */ + ownedSessionIds: string[] + /** True when this database saw sessions already claimed by another database. + * When that owner disappears, only deferred databases need reparse. */ + hasDeferredClaims: boolean } export type OpenCodeUsagePersistedState = { From 620b475acdca9e6783e4f39ac77db27620108a92 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:41:58 -0700 Subject: [PATCH 13/17] Bug wsl terminal zsh shell ready wrapper exports (#8025) * Fix WSL zsh ZDOTDIR restore using runtime-sourced path instead of baked Windows-generated shell-ready wrappers are sourced via /mnt/c on WSL, where the generation-time path baked into .zshenv doesn't exist. Capture the runtime ZDOTDIR before it's unset and prefer it (when it still points to a valid wrapper dir) over the baked literal when restoring ZDOTDIR, so user .zshrc/.zshenv files load correctly under WSL. * Fix zsh ZDOTDIR self-restore to guard against unset variables Add ":-" default expansion when referencing _orca_wrapper_zdotdir_self so the wrapper doesn't abort under `set -u` when the variable is unset, and extend the shell-ready test to assert login-shell behavior (IS_LOGIN) and improve failure diagnostics on zsh spawn. --- .../providers/local-pty-shell-ready.test.ts | 80 ++++++++++++++++++- src/main/shell-templates.ts | 17 +++- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index d0a6ba13764..e72fc25cbf8 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join, dirname } from 'node:path' -import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' +import { mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' import type * as pty from 'node-pty' import type * as LocalPtyShellReadyModule from './local-pty-shell-ready' import { @@ -724,6 +724,31 @@ describePosix('local PTY shell-ready launch config', () => { // Fallback chain: discovered → normalized spawn-env path → HOME expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') }) + + it('restores wrapper ZDOTDIR from the runtime sourced path, not the baked literal', async () => { + // Why: issue #8003 — WSL sources Windows-generated wrappers via /mnt/c, + // so the generation-time path baked into .zshenv does not exist there. + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + + getShellReadyLaunchConfig('/bin/zsh') + + const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + + expect(zshenv).toContain('_orca_wrapper_zdotdir_self="${ZDOTDIR:-}"') + // The runtime path is only trusted when it still holds a wrapper .zshenv; + // otherwise the generation-time literal remains as the fallback. + expect(zshenv).toContain( + 'if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then\n' + + ' export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}"\n' + + 'else\n' + + ` export ZDOTDIR='${join(userDataPath, 'shell-ready', 'zsh')}'\n` + + 'fi' + ) + // Capture must happen before the wrapper unsets ZDOTDIR to source user files. + expect(zshenv.indexOf('_orca_wrapper_zdotdir_self="${ZDOTDIR:-}"')).toBeLessThan( + zshenv.indexOf('unset ZDOTDIR') + ) + }) }) // Why: end-to-end validation that wrapper ZDOTDIR discovery preserves top-level @@ -812,6 +837,59 @@ path=(/custom/bin $path) expect(output).toContain('PATH_HAS_CUSTOM=/custom/bin') }) + it('loads user .zshrc when wrappers are sourced from a different runtime path (WSL simulation)', async () => { + // Why: issue #8003 — on Windows the wrappers are generated under the + // native userData path but WSL sources them via /mnt/c, where the baked + // generation-time path does not exist. Renaming the userData dir after + // generation reproduces that split: runtime ZDOTDIR resolves, the baked + // literal does not. + writeFileSync(join(testHome, '.zshrc'), 'export USER_ZSHRC_LOADED=yes\n') + + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + getShellReadyLaunchConfig('/bin/zsh') + + const movedUserData = `${userDataPath}-wsl-view` + renameSync(userDataPath, movedUserData) + try { + const cleanEnv: Record = { + ...process.env, + HOME: testHome, + PATH: '/usr/bin:/bin' + } + delete cleanEnv.ZDOTDIR + delete cleanEnv.ORCA_ORIG_ZDOTDIR + delete cleanEnv.ORCA_ATTRIBUTION_SHIM_DIR + delete cleanEnv.USER_ZSHRC_LOADED + cleanEnv.ZDOTDIR = join(movedUserData, 'shell-ready', 'zsh') + + // Production WSL launches a login shell (`exec zsh -l`); also cover the + // non-login flow used by local panes so both restore paths stay pinned. + // Login must still load user .zshrc (via wrapper .zshrc after .zprofile) + // and leave final ZDOTDIR at the user home after .zlogin restore. + for (const args of [['-i'], ['-l', '-i']] as const) { + const result = spawnSync( + 'zsh', + [ + ...args, + '-c', + 'echo "USER_ZSHRC_LOADED=${USER_ZSHRC_LOADED:-no}" && echo "FINAL_ZDOTDIR=${ZDOTDIR:-unset}" && echo "IS_LOGIN=$([[ -o login ]] && echo yes || echo no)"' + ], + { + env: cleanEnv as NodeJS.ProcessEnv, + encoding: 'utf8' + } + ) + + expect(result.status, `zsh ${args.join(' ')} failed: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('USER_ZSHRC_LOADED=yes') + expect(result.stdout).toContain(`FINAL_ZDOTDIR=${testHome}`) + expect(result.stdout).toContain(args.includes('-l') ? 'IS_LOGIN=yes' : 'IS_LOGIN=no') + } + } finally { + rmSync(movedUserData, { recursive: true, force: true }) + } + }) + it('preserves top-level .zshenv path and function side effects', async () => { // Why: .zshenv is the normal place for always-on zsh env/path setup. // Dropping those side effects regresses non-Orca zsh startup semantics. diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index dccd5e2c87f..d11bdbcfbc0 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -10,6 +10,13 @@ export function getZshEnvTemplate(zshDir: string, headerPrefix = ''): string { ? `Orca ${headerPrefix} zsh shell-ready wrapper` : 'Orca zsh shell-ready wrapper' return `# ${header} +# Why: capture the runtime wrapper dir before it is unset below. On WSL this +# file is generated with a Windows path but sourced via /mnt/c, so the baked +# literal is unusable there and ZDOTDIR must be restored from this value. +_orca_wrapper_zdotdir_self="\${ZDOTDIR:-}" +while [[ "\${_orca_wrapper_zdotdir_self:-}" == */ ]]; do + _orca_wrapper_zdotdir_self="\${_orca_wrapper_zdotdir_self%/}" +done _orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}" _orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}" _orca_zshenv_source_dir="\${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" @@ -67,8 +74,14 @@ case "\${ORCA_ORIG_ZDOTDIR}" in ""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; esac -export ZDOTDIR=${quotePosixSingle(zshDir)} -unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir +# Why: use :- after user .zshenv — a pathological unset under set -u must not +# abort the wrapper; empty falls through to the baked-literal branch. +if [[ -n "\${_orca_wrapper_zdotdir_self:-}" && -f "\${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then + export ZDOTDIR="\${_orca_wrapper_zdotdir_self:-}" +else + export ZDOTDIR=${quotePosixSingle(zshDir)} +fi +unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir _orca_wrapper_zdotdir_self ` } From bab126fb2c34ab9623d8afde57fdd7bb863224aa Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:44:12 -0700 Subject: [PATCH 14/17] fix: close terminal tabs when killing all sessions (#8027) Implement the surface cleanup described in docs/kill-all-sessions-also-kills-empty-terminals.md. Fixes #8001. --- config/reliability-gates.jsonc | 130 ++++++- ...all-sessions-also-kills-empty-terminals.md | 203 ++++++++++ .../shared/kill-all-terminal-surfaces.test.ts | 365 ++++++++++++++++++ .../shared/kill-all-terminal-surfaces.ts | 215 +++++++++++ .../shared/useDaemonActions.test.tsx | 152 ++++++++ .../components/shared/useDaemonActions.tsx | 150 ++++--- .../terminal-tab-actions-kill-all.test.ts | 131 +++++++ src/renderer/src/i18n/locales/en.json | 12 +- src/renderer/src/i18n/locales/es.json | 12 +- src/renderer/src/i18n/locales/ja.json | 12 +- src/renderer/src/i18n/locales/ko.json | 12 +- src/renderer/src/i18n/locales/zh.json | 12 +- 12 files changed, 1352 insertions(+), 54 deletions(-) create mode 100644 docs/kill-all-sessions-also-kills-empty-terminals.md create mode 100644 src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts create mode 100644 src/renderer/src/components/shared/kill-all-terminal-surfaces.ts create mode 100644 src/renderer/src/components/shared/useDaemonActions.test.tsx create mode 100644 src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 36291362d1e..464b814e711 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-08", + "updatedAt": "2026-07-09", "policy": { "maturityLevels": [ "experimental", @@ -105,6 +105,134 @@ ], "demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner." }, + { + "id": "terminal-session.kill-all-surface-cleanup", + "title": "Kill all sessions removes only the confirmed terminal surfaces and current bindings", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-main-contract", + "surfaces": [ + "terminal lifecycle", + "terminal tab cleanup", + "PTY shutdown", + "Manage Sessions", + "Resource Manager" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime", + "mobile-relay" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "daemon", + "ssh" + ], + "coverageNotes": "Local macOS deterministic evidence covers the renderer snapshot/coordinator, exact local and SSH-shaped PTY request settlement, active-last and pinned terminal-tab routing, component-unmount continuation, and the existing current/legacy daemon management contract. Windows Electron process absence, live SSH/WSL behavior, and remote-runtime/mobile host completion remain explicit gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/8001" + ], + "invariant": "Every terminal surface confirmed in the invoking renderer is force-closed exactly once after daemon management settles, later-created surfaces and non-terminal tabs survive, and exact shutdown requests are limited to deduplicated current non-runtime PTY bindings of the confirmed surfaces.", + "oracle": "Snapshot terminal entity IDs before the first await; mutate ownership, active selection, bindings, and tab presence while daemon management is pending; then assert only the immutable targets disappear from both terminal stores, active targets close last with valid editor/browser/deactivated post-state, every captured exact PTY promise settles before callbacks, and no provider inventory sweep or late-tab kill occurs.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts src/renderer/src/components/shared/useDaemonActions.test.tsx src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts src/main/ipc/pty-management.test.ts" + ], + "testFiles": [ + "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", + "src/renderer/src/components/shared/useDaemonActions.test.tsx", + "src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts", + "src/main/ipc/pty-management.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", + "assertions": [ + "snapshot deduplicates legacy, unified-only, split, multi-worktree, and floating terminal surfaces while excluding editor tabs", + "cleanup-time moves, active-worktree switches, rebinding, missing targets, and later-created tabs preserve the confirmation boundary and active-last order", + "current exact PTY bindings are deduplicated, remote runtime IDs and stale/late bindings are excluded, and all per-PTY settlements finish before completion", + "the production dependency path calls daemon management exactly once and never invokes listSessions for a post-kill sweep", + "management rejection and per-close/provider failures do not stop remaining cleanup and produce bounded count/latency diagnostics", + "a real 100-tab Zustand fixture records 100 close attempts and exact kills, at least 100 writes, 49 event-loop yields, and no close batch over 50 ms" + ] + }, + { + "file": "src/renderer/src/components/shared/useDaemonActions.test.tsx", + "assertions": [ + "the hook snapshots before onKillAllStart and before coordinator work", + "unmounting the invoking component does not revoke cleanup while React callbacks remain mount-gated", + "error and settled callbacks run only after coordinator settlement", + "closed terminal tabs report success instead of the no-sessions informational state when daemon management reports zero" + ] + }, + { + "file": "src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts", + "assertions": [ + "force closes pinned terminals without a second confirmation", + "closing the last active terminal preserves and activates editor or browser content, otherwise deactivates without auto-spawn" + ] + }, + { + "file": "src/main/ipc/pty-management.test.ts", + "assertions": [ + "killAll fires one shutdown for each initial daemon session and polls those initial IDs until empty", + "freshly respawned session IDs are excluded from remainingCount", + "per-session shutdown rejection does not stop the daemon batch and refused initial sessions remain reported" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-09", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts src/renderer/src/components/shared/useDaemonActions.test.tsx src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts src/main/ipc/pty-management.test.ts", + "result": "passed", + "durationSeconds": 1.31, + "summary": "4 test files and 27 tests passed locally, including the existing daemon handler contract and a real 100-terminal Zustand cleanup fixture." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "focused renderer/main unit and performance-count tests" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The focused 27-test slice passed locally once; it needs CI and soak history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "The initial 100-tab implementation exposed a 145.82 ms close batch and failed the 50 ms budget; two-close event-loop batching reduced the focused run below the budget. Saved CI artifacts and intentional-break evidence for the confirmation-boundary assertions are still needed." + }, + "performanceBudget": { + "required": true, + "evidence": "The coordinator performs one existing management call, zero provider inventory sweeps, one close per present unique target, and at most one exact kill per unique current non-runtime PTY binding. A real 100-tab Zustand fixture asserts 100 close attempts, 100 exact kill calls, at least 100 store writes, 49 event-loop yields, and a maximum two-close batch at or below 50 ms." + }, + "promotionCriteria": [ + "Run the focused gate for at least 100 consecutive passes or 14 days across required CI platforms.", + "Attach Windows Electron evidence for both entry points, empty and established terminals, later-tab survival, xterm removal, and initial PTY absence after settlement.", + "Exercise the SSH fixture and WSL when available, or keep their live process-absence gaps explicit.", + "Propagate and align runtime-host tab-close completion before claiming verified remote-runtime shutdown." + ], + "knownGaps": [ + "Windows Electron screenshots and live process/xterm absence evidence were not produced by this local macOS run.", + "Live SSH and WSL process absence, Linux local/daemon behavior, and mobile/relay shutdown remain unproved.", + "Runtime-host terminal close is best-effort because closeTerminalTab still discards the existing async host result and its close-intent lifetime is shorter than the possible RPC flow.", + "Daemon adapter listing failures remain suppressed by the existing management API, so reported daemon counts are not authoritative verification of every process." + ], + "demotionRule": "Keep experimental or demote to protection none if the gate flakes, permits a late-created tab or unrelated PTY to close, or exceeds the 50 ms maximum close-batch budget without a tracked product or harness bug." + }, { "id": "terminal-session.startup-cwd-missing-dir-recovery", "title": "Fresh local terminal creation cannot be bricked by a deleted startup cwd", diff --git a/docs/kill-all-sessions-also-kills-empty-terminals.md b/docs/kill-all-sessions-also-kills-empty-terminals.md new file mode 100644 index 00000000000..438514cec63 --- /dev/null +++ b/docs/kill-all-sessions-also-kills-empty-terminals.md @@ -0,0 +1,203 @@ +# Kill all sessions also closes terminal tabs + +GitHub: https://github.com/stablyai/orca/issues/8001 +Branch: `bug-kill-all-sessions-doesnt-kill-terminals` + +## Problem + +Settings → Manage Sessions and Resource Manager expose **Kill all sessions**. The action currently calls only `window.api.pty.management.killAll()`; it does not remove renderer terminal tabs or dispose xterm instances. + +The main handler is narrower than the UI wording: + +- `pty:management:killAll` snapshots sessions from the current and legacy **daemon** adapters, shuts them down in parallel, then polls only those initial IDs. +- It does not inventory SSH or runtime-hosted terminals. A degraded local provider is also outside the management adapter set. +- The nominal poll sleep budget is 6.5 seconds (65 × 100 ms), but adapter round-trip time is additional. Daemon requests time out after 30 seconds and connection setup has separate 5-second steps, so total latency can be much longer. +- Failed adapter listings are dropped by `collectSessions`, so counts are not authoritative when an adapter is unreachable. This applies during polling too: an adapter that listed an initial session and then becomes unreachable can make that session disappear from `remainingCount` and be counted as killed without a confirming listing. + +Renderer exit handling then preserves two important startup-failure states: + +- a sole, freshly spawned pane that exits before any user input; and +- a freshly split pane that exits before input or output. + +Those guards are correct for direnv/shell setup failures, but they cannot distinguish an explicit bulk kill. A sole fresh-spawned pane remains if the user never typed, even if it produced output or lived for a while; typed/reattached panes normally close through the existing exit path. + +## Goal and invariant + +After confirmation, every terminal tab that existed in the invoking desktop renderer at that moment is removed, including dead/no-PTY tabs, and its renderer resources are disposed. Terminal tabs created later are not targeted. Initial daemon sessions still receive the existing management shutdown request, while current non-runtime PTY bindings owned by the targeted tabs receive exact per-ID shutdown requests. + +The target is the confirmed terminal **surface ID**, not a liveness inference. If the same targeted tab rebinds before cleanup completes, it is still closed; a newly created tab ID survives. + +## Non-goals + +- Changing **Restart daemon**, which intentionally leaves panes available to reopen. +- Deleting or sleeping worktrees, closing browser/editor tabs, or killing the daemon process. +- Changing the sole-pane/fresh-split startup-failure guards. +- Promising an immediate OS working-set drop; GC and allocator behavior are nondeterministic. The deterministic contract is that tabs/xterms are released and observable daemon/exact-binding shutdown-request failures are surfaced. Runtime-host result propagation remains an accepted gap below. +- Turning this issue into a new cross-provider global kill API. Exact `pty.kill(id)` calls for current bindings of the confirmed tabs are in scope; inventorying and killing unrelated provider sessions is not. + +## Design + +### 1. Snapshot terminal surfaces once + +At confirmation, before `onKillAllStart` and before the first `await`, collect and deduplicate terminal entity IDs from both `tabsByWorktree` and terminal entries in `unifiedTabsByWorktree`. Include the floating-terminal workspace. + +Keep this immutable target set through the async daemon call: + +- a target already closed elsewhere becomes a no-op; +- a target moved between groups/worktrees is resolved from current state and still closes; +- a terminal created after the snapshot is never closed. + +Do not snapshot all terminal IDs again after the daemon call, and do not retain the confirmation-time worktree as cleanup authority. The target ID is immutable; ownership and active-last ordering are resolved again from current state immediately before cleanup. + +### 2. Keep daemon shutdown semantics, then close the snapshot + +Call the existing `window.api.pty.management.killAll()` once. After it settles, resolve the still-present targets and deduplicate their current `ptyIdsByTabId` entries. These are the renderer's current binding ownership records, not independent liveness proof; do not pull IDs from `tab.ptyId`, deferred SSH restore state, or `terminalLayoutsByTabId.ptyIdsByLeafId`, which can be restore hints rather than current ownership. + +Force-close every snapshotted surface through `closeTerminalTab(id, { force: true })`, then await one existing `window.api.pty.kill(id)` call for each captured non-`remote:` PTY ID. Run both phases for management success, partial success, and IPC rejection because the confirmation explicitly covers closing the terminal surfaces. Use per-target/per-PTY settlement so one unexpected store or provider failure does not stop the remaining cleanup. Already-gone PTYs are successful no-ops in the main handler; other exact-kill rejections are reported separately. + +Close targets outside the cleanup-time `activeWorktreeId` first and targets currently owned by it last. A tab moved into the active worktree or a workspace selected while the daemon call was pending must therefore move to the last partition. Let `closeTerminalTab` choose the existing post-state for the last local terminal: editor, then browser, then `setActiveWorktree(null)`. Do not pre-clear `activeWorktreeId`; the sleep flow needs that ordering because it leaves tab records mounted while clearing PTYs, whereas this flow removes the tabs. Add active-switch and active-last regression tests and only adopt sleep-intent/deactivate-first if those tests prove a respawn. + +This is not one backend call end-to-end. The coordinator issues one daemon-management IPC, one renderer close per unique target, and at most one exact kill IPC per unique current non-runtime PTY binding. The explicit exact kills make local/SSH shutdown requests settle before caller refresh instead of relying on React unmount timing. A mounted transport may race that request and issue an idempotent duplicate during unmount; count that separately in live performance evidence. A still-bound daemon session can receive the exact retry after the management poll, so daemon toast counts remain the management handler's earlier reported snapshot, not final process truth. Runtime-hosted tabs additionally issue one `session.tabs.close` flow per tab. + +The coordinator must live outside the hook (for example, `kill-all-terminal-surfaces.ts`) and must not gate cleanup on `mountedRef`. Navigating away or unmounting the invoking popover/settings component while IPC is pending does not revoke an already confirmed destructive action; only React callbacks and hook state updates remain mount-gated. + +### 3. Do not add a post-kill orphan sweep + +Do not call `pty.listSessions()` and kill everything returned after the management call. Exact kills captured from `ptyIdsByTabId` for the immutable target surfaces are allowed and required; they do not discover or cross into unrelated sessions. + +The management handler already targets daemon orphans in its initial snapshot. A later broad sweep would cross the confirmation boundary by killing local/SSH sessions or fresh sessions created while the existing poll was running. It would also add provider inventory fan-out to a recovery action and conflict with the main handler's initial-ID accounting. + +The existing explicit **Kill orphan terminals** action remains separate. + +### 4. Make completion and failure copy honest + +Have the renderer cleanup return a bounded summary: target count, targets absent at completion, failed close attempts, exact PTY kill requests accepted/rejected, and daemon result/error. Verify absence in both terminal and unified state after each close attempt. Toasts must report renderer cleanup separately from daemon counts; do not show **No sessions running** when terminal tabs were closed. + +Because the management API suppresses adapter-list failures, copy must describe daemon counts as reported, not claim that every daemon process was verified dead. Returning adapter errors is a follow-up unless this PR chooses to widen the API. Exact `pty.kill` rejection counts may be stated as failed shutdown requests, not as proof that the processes remain alive. + +Examples of states, not required wording: + +- all daemon targets were reported exited, exact shutdown requests were accepted, and tabs closed: success; +- `remainingCount > 0`: warning that management reported daemon processes not exited before exact binding cleanup; +- management IPC rejected: error that tabs closed, with daemon shutdown unverified and any exact PTY kill failures called out; +- no daemon sessions and no terminal tabs: existing informational state. + +Run `onKillAllSettled` only after surface removal and exact PTY kill settlement. `closeTerminalTab` returns before React unmount effects run, so surface removal alone is not a sufficient boundary for Resource Manager's `pty.listSessions()` refresh. Keep one confirmation dialog and update its description to say that terminal tabs across workspaces close and unsaved terminal work is lost. + +### 5. Provider limits + +`closeTerminalTab` synchronously prunes host-backed local mirrors but currently returns `void` and discards the promise from `closeWebRuntimeSessionTab`. The underlying helper is already awaitable: its close request has a 15-second timeout and, after success, it awaits an eager list refresh with another 15-second timeout. Its boolean can therefore settle after roughly 30 seconds, while close-intent suppression expires after 10 seconds. This change must not claim verified runtime-host completion unless the bulk helper propagates that existing result and aligns the intent lifetime. + +For this Windows/local bug, runtime-host cleanup is best-effort and an explicit accepted gap. A follow-up may propagate the existing async close result through terminal-tab actions and align the RPC timeout/intent lifetime. SSH must be exercised if the fixture is available; otherwise record live SSH process absence as an accepted gap. No provider-specific local filesystem or process assumptions are added. + +## Data flow + +```text +confirm + → snapshot unique terminal surface IDs + → pty.management.killAll() daemon current + legacy; existing polling + → resolve current target ownership + bound PTY IDs + → close snapshotted tabs, active last renderer/xterm cleanup; runtime-host close starts + → await exact non-runtime pty.kill IDs local/SSH acknowledgement; no provider sweep + → toast daemon result + surface/exact-kill summary + → caller refresh +``` + +## Concurrency and consistency + +| Case | Required behavior | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| No daemon sessions; dead/empty tabs exist | Close target tabs and report tab cleanup, not “No sessions.” | +| Daemon session refuses to exit | Close the confirmed tab; report that management saw it remaining before the exact retry. Refresh may expose it as an orphan. | +| Adapter listing fails | Close target tabs; do not claim all processes were verified dead. Existing management counts may read as zero. | +| Management IPC rejects | Still close confirmed tabs and issue exact current-binding kills; report daemon uncertainty and exact-kill errors. | +| New tab/session during the wait | New tab ID survives. No post-snapshot provider sweep. | +| Target closes or moves during the wait | Missing target no-ops; moved target closes by current ownership. | +| Target tab rebinds to a new PTY | Close it because the confirmed surface, not the old PTY snapshot, is the authority. | +| Pinned or split terminal | `force: true` bypasses a second prompt; closing the tab closes all of its panes. | +| Active last terminal | Existing close routing selects editor/browser or deactivates; no replacement terminal spawns. | +| Two overlapping invocations | Cleanup is idempotent by tab ID. Each main call retains its own initial daemon snapshot/counts. | +| Binding changes after exact-ID capture | Surface close still wins; transport unmount kills the replacement, but that late kill is not in the awaited count. | +| Runtime-host snapshot races close | Existing close intent suppresses stale snapshots temporarily; host completion remains an accepted gap. | +| External/mobile session creation | Sessions created after the main/renderer snapshots are not chased. Existing PTY-exit publication updates mirrors. | + +Orca's production UI currently tracks a single desktop `mainWindow`/renderer. The main daemon action is process-global while renderer cleanup is renderer-local. If multiple desktop windows become supported, this action must be broadcast from main with an action ID and per-window acknowledgement; “shared store probably mirrors it” is not sufficient. + +## Tests and reliability gate + +Add an experimental, `protection: "partial"` `terminal-session.kill-all-surface-cleanup` entry to `config/reliability-gates.jsonc`, owned by `terminal-runtime` at the renderer/main contract layer. The entry must name the focused coordinator and `pty-management` test files/commands and include non-empty assertion refs; otherwise the manifest checker rejects a partial gate. + +- **Invariant:** confirmed terminal surfaces close exactly once; later-created surfaces survive; no broad post-snapshot kill occurs. +- **Failure source:** issue #8001 plus the sole-fresh/fresh-split exit guards that intentionally preserve dead startup-failure panes. +- **Deterministic oracle:** the initial tab IDs disappear from both terminal/unified state, non-terminal and later-created tabs remain, active state is valid, exact kills are issued only for deduplicated current target bindings, all exact-kill promises settle before the caller callback, and no `pty.listSessions` sweep occurs. Provider inventory absence is live validation, not the renderer-unit oracle. +- **Diagnostics:** emit one content-free bounded summary with target/absent/failed-close counts, exact-kill accepted/rejected counts, and the daemon result or error. Runtime-host completion remains unknown until terminal-tab actions propagate the existing async close result. + +Required deterministic coverage: + +- snapshot/dedup across multiple worktrees, unified-only terminals, splits, and floating terminals; +- pinned close with `force`, while browser/editor tabs remain; +- terminal added after the snapshot survives; missing/moved target behavior; +- active-last post-state for editor, browser, and no-other-content cases, including no auto-spawn; +- coordinator ordering for success, partial result, zero sessions, and rejected management IPC; +- exact binding capture excludes restore hints and `remote:` IDs; late-created tab bindings are not captured; +- exactly one management call, zero `pty.listSessions` calls, one close attempt per unique target, and one coordinator-issued exact kill per unique captured non-runtime PTY ID; +- invoking component unmount during the management wait does not cancel store/provider cleanup or invoke mount-gated callbacks; +- existing `src/main/ipc/pty-management.test.ts` cases remain green. + +Electron validation on Windows must reproduce an empty shell plus an established terminal, invoke each entry point, and prove: + +- target terminal tabs and `.xterm` surfaces are gone; +- initial local/daemon PTY IDs are absent after settle, except daemon IDs reported remaining and exact IDs whose shutdown request failed; +- a terminal created after confirmation survives; +- Resource Manager and Manage Sessions refresh to the same result. + +Planned provider/platform accounting: + +| Row | Planned status | +| --- | --- | +| Local PTY / daemon / Windows | Covered by deterministic coordinator/main tests plus the required live Electron run. | +| macOS / Linux local and daemon | Shared code is covered deterministically; live process absence remains an accepted gap until run there. | +| SSH | Exact request/settlement is covered by provider-contract mocks; live remote-process absence is covered only when the SSH fixture runs, otherwise accepted-gap. | +| WSL | Uses the local exact-ID path, but real WSL process absence is an accepted gap unless included in the Windows run. | +| Remote runtime | Local mirror removal is covered; host completion is accepted-gap while `closeTerminalTab` discards the async result. | +| Mobile/relay | No newly created session is chased; shared host-tab disappearance and live relay shutdown remain accepted gaps unless exercised with their fixtures. | + +Screenshots are UI evidence only; they do not prove PTY or memory cleanup. + +## Performance and blast radius + +No interval, watcher, or extra provider listing is added. The existing management call can wait for 65 sleeps plus adapter latency. The coordinator adds at most one existing exact-kill IPC per unique current non-runtime PTY binding; record both those calls and any idempotent unmount duplicates as part of the performance evidence. + +Repeated `closeTerminalTab` calls are not O(tabs): target resolution and store cascades scan/clone multiple maps per close, so worst-case renderer work is superlinear. Measure the synchronous close phase with a representative high-tab fixture (at least 100 terminal tabs). Record close attempts, Zustand writes, duration, and long tasks. If it creates a >50 ms renderer task, add a focused bulk reducer or yield-safe batching rather than declaring the rare action free. + +For resource reclamation, prefer deterministic counts: zero target `TerminalPane`/xterm instances, released listeners/transports, and no initial PTY IDs. A repeated open/kill cycle may supplement this with heap/working-set evidence, but absolute memory deltas are not a stable pass/fail oracle. + +## UI quality bar and screenshots + +No new layout or visual token is needed. Keep the existing shadcn `Dialog`/destructive `Button`, compact typography, dismissal lock while busy, and accurate consequence-first copy. The post-state must never be a blank/crashed TerminalPane or a wall of dead empty panes. + +Required review screenshots: + +1. Resource Manager before kill with an empty terminal and an established terminal visible in the workspace. +2. Resource Manager kill-all confirmation with updated close-tabs copy. +3. Matched post-kill workspace showing target terminal tabs gone and refreshed counts. +4. Manage Sessions confirmation or post-state, proving the second entry point uses the same behavior. + +## Lightweight Eng Review + +- **Scope:** renderer surface cleanup layered on the existing daemon management action; no daemon redesign and no broad orphan/provider sweep. +- **Architecture/data flow:** main owns its initial daemon-session shutdown; renderer owns the confirmed terminal-surface snapshot, exact current-binding shutdown, and tab/xterm disposal; runtime-host close remains behind existing routing. +- **Failure modes:** partial/rejected daemon kill, exact provider-kill failure, no-session cleanup, pinning, splits, cleanup-time active selection, new/moved/disappeared tabs, same-tab rebind, overlapping actions, invoking-component unmount, stale runtime snapshots, adapter-list uncertainty, and future multi-window ownership are explicit above. +- **Tests:** deterministic state/coordinator and main-handler tests plus Windows Electron proof; a new experimental partial reliability gate records provider gaps. +- **Performance/blast radius:** one existing management IPC, bounded exact per-PTY kill IPCs, no new listing/polling, but N close cascades require measurement because current helpers are not linear or atomic. +- **UI quality bar:** existing dialog primitives and truthful copy; screenshots prove both entry points and the terminal-free post-state, not memory reclamation. +- **Residual risks:** daemon adapter listing can stall until client timeouts and failures are treated as empty; terminal-tab actions discard the runtime-host close result and its 10-second intent is shorter than the possible two-request close flow; a binding created after exact-ID capture is killed only by transport unmount; live SSH/WSL/mobile behavior may remain accepted gaps until fixtures prove it. + +## Rollout + +1. Add the snapshot/coordinator and deterministic tests. +2. Wire it only through `useDaemonActions.runKillAll`; keep the coordinator outside the hook and update toast/dialog copy. +3. Add the experimental reliability gate and performance count/latency evidence. +4. Run focused tests, typecheck, lint, and max-lines ratchet checks. +5. Validate both entry points in Electron on Windows and capture the required screenshots. +6. Force-add this doc when staging because `docs/**` is ignored by default. diff --git a/src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts b/src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts new file mode 100644 index 00000000000..09de670c689 --- /dev/null +++ b/src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { useAppStore, type AppState } from '@/store' +import { + runKillAllTerminalSurfaces, + snapshotKillAllTerminalSurfaceIds, + type KillAllTerminalSurfaceState +} from './kill-all-terminal-surfaces' + +type TerminalRow = AppState['tabsByWorktree'][string][number] +type UnifiedRow = AppState['unifiedTabsByWorktree'][string][number] + +function terminal(id: string, worktreeId: string): TerminalRow { + return { id, worktreeId } as TerminalRow +} + +function unified( + id: string, + entityId: string, + worktreeId: string, + contentType: UnifiedRow['contentType'] = 'terminal' +): UnifiedRow { + return { id, entityId, worktreeId, contentType } as UnifiedRow +} + +function state(overrides: Partial = {}): KillAllTerminalSurfaceState { + return { + activeWorktreeId: null, + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + ptyIdsByTabId: {}, + ...overrides + } +} + +function removeSurface(current: KillAllTerminalSurfaceState, targetId: string): void { + current.tabsByWorktree = Object.fromEntries( + Object.entries(current.tabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.filter((tab) => tab.id !== targetId) + ]) + ) + current.unifiedTabsByWorktree = Object.fromEntries( + Object.entries(current.unifiedTabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.filter( + (tab) => + tab.contentType !== 'terminal' || (tab.entityId !== targetId && tab.id !== targetId) + ) + ]) + ) +} + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (reason: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +describe('snapshotKillAllTerminalSurfaceIds', () => { + it('deduplicates terminal entities across legacy, unified, split, and floating state', () => { + const snapshot = snapshotKillAllTerminalSurfaceIds( + state({ + tabsByWorktree: { + 'wt-a': [terminal('tab-a', 'wt-a'), terminal('split-tab', 'wt-a')], + 'wt-b': [terminal('tab-b', 'wt-b')], + [FLOATING_TERMINAL_WORKTREE_ID]: [terminal('floating-tab', FLOATING_TERMINAL_WORKTREE_ID)] + }, + unifiedTabsByWorktree: { + 'wt-a': [ + unified('visible-a', 'tab-a', 'wt-a'), + unified('unified-only', 'unified-only', 'wt-a'), + unified('editor-a', 'file-a', 'wt-a', 'editor') + ], + [FLOATING_TERMINAL_WORKTREE_ID]: [ + unified('floating-visible', 'floating-tab', FLOATING_TERMINAL_WORKTREE_ID) + ] + }, + ptyIdsByTabId: { + 'split-tab': ['pty-left', 'pty-right'] + } + }) + ) + + expect(snapshot).toEqual(['tab-a', 'split-tab', 'tab-b', 'floating-tab', 'unified-only']) + }) +}) + +describe('runKillAllTerminalSurfaces', () => { + it('resolves current bindings and ownership after management settles, then closes active last', async () => { + const management = deferred<{ killedCount: number; remainingCount: number }>() + const lastExactKill = deferred() + let current = state({ + activeWorktreeId: 'wt-old-active', + tabsByWorktree: { + 'wt-old-active': [terminal('moved-target', 'wt-old-active')], + 'wt-background': [ + terminal('background-target', 'wt-background'), + terminal('missing-target', 'wt-background') + ] + }, + unifiedTabsByWorktree: { + 'wt-background': [unified('unified-target', 'unified-only-target', 'wt-background')] + }, + ptyIdsByTabId: { + 'moved-target': ['stale-pty'], + 'background-target': ['stale-background-pty'], + 'missing-target': ['stale-missing-pty'], + 'unified-only-target': ['stale-unified-pty'] + } + }) + const targetIds = snapshotKillAllTerminalSurfaceIds(current) + const calls: string[] = [] + const killDaemonSessions = vi.fn(() => management.promise) + const closeSurface = vi.fn((targetId: string) => { + calls.push(`close:${targetId}`) + removeSurface(current, targetId) + }) + const killPty = vi.fn((ptyId: string) => { + calls.push(`kill:${ptyId}`) + if (ptyId === 'pty-unified') { + return Promise.reject(new Error('provider unavailable')) + } + if (ptyId === 'ssh:host@@pty-last') { + return lastExactKill.promise + } + return Promise.resolve() + }) + const reportSummary = vi.fn() + + const completion = runKillAllTerminalSurfaces(targetIds, { + getState: () => current, + killDaemonSessions, + closeSurface, + killPty, + reportSummary + }) + + expect(killDaemonSessions).toHaveBeenCalledTimes(1) + expect(closeSurface).not.toHaveBeenCalled() + + current = state({ + activeWorktreeId: 'wt-new-active', + tabsByWorktree: { + 'wt-background': [terminal('background-target', 'wt-background')], + 'wt-new-active': [ + { + ...terminal('moved-target', 'wt-new-active'), + ptyId: 'tab-restore-hint' + }, + terminal('later-tab', 'wt-new-active') + ] + }, + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + unified('unified-target-moved', 'unified-only-target', FLOATING_TERMINAL_WORKTREE_ID) + ], + 'wt-new-active': [unified('later-visible', 'later-tab', 'wt-new-active')] + }, + ptyIdsByTabId: { + 'background-target': ['pty-shared', 'remote:runtime-only'], + 'unified-only-target': ['pty-unified', 'pty-shared'], + 'moved-target': ['ssh:host@@pty-last', 'pty-shared'], + 'later-tab': ['pty-later'] + } + }) + ;( + current as KillAllTerminalSurfaceState & { + terminalLayoutsByTabId: Record }> + } + ).terminalLayoutsByTabId = { + 'moved-target': { ptyIdsByLeafId: { leaf: 'layout-restore-hint' } } + } + management.resolve({ killedCount: 2, remainingCount: 1 }) + await vi.waitFor(() => expect(killPty).toHaveBeenCalledTimes(3)) + + expect(closeSurface.mock.calls).toEqual([ + ['background-target', { force: true }], + ['unified-only-target', { force: true }], + ['moved-target', { force: true }] + ]) + expect(calls.slice(0, 3)).toEqual([ + 'close:background-target', + 'close:unified-only-target', + 'close:moved-target' + ]) + expect(killPty).toHaveBeenCalledWith('pty-shared') + expect(killPty).toHaveBeenCalledWith('pty-unified') + expect(killPty).toHaveBeenCalledWith('ssh:host@@pty-last') + expect(killPty).not.toHaveBeenCalledWith('remote:runtime-only') + expect(killPty).not.toHaveBeenCalledWith('pty-later') + expect(killPty).not.toHaveBeenCalledWith('stale-pty') + expect(killPty).not.toHaveBeenCalledWith('tab-restore-hint') + expect(killPty).not.toHaveBeenCalledWith('layout-restore-hint') + expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['later-tab']) + + let settled = false + void completion.then(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + lastExactKill.resolve() + + await expect(completion).resolves.toMatchObject({ + targetCount: 4, + closeAttemptCount: 3, + absentTargetCount: 4, + failedCloseAttemptCount: 0, + exactKillAcceptedCount: 2, + exactKillRejectedCount: 1, + daemon: { status: 'fulfilled', killedCount: 2, remainingCount: 1 } + }) + expect(reportSummary).toHaveBeenCalledTimes(1) + }) + + it('still closes and issues exact kills when daemon management rejects', async () => { + let current = state({ + tabsByWorktree: { wt: [terminal('target', 'wt'), terminal('later', 'wt')] }, + ptyIdsByTabId: { target: ['local-pty'], later: ['later-pty'] } + }) + const closeSurface = vi.fn((targetId: string) => removeSurface(current, targetId)) + const killPty = vi.fn().mockResolvedValue(undefined) + + const summary = await runKillAllTerminalSurfaces(['target'], { + getState: () => current, + killDaemonSessions: vi.fn().mockRejectedValue(new Error('management unavailable')), + closeSurface, + killPty, + reportSummary: vi.fn() + }) + + expect(summary.daemon).toEqual({ status: 'rejected' }) + expect(summary.absentTargetCount).toBe(1) + expect(closeSurface).toHaveBeenCalledWith('target', { force: true }) + expect(killPty).toHaveBeenCalledWith('local-pty') + expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['later']) + }) + + it('reports the informational zero state without provider fanout', async () => { + const killDaemonSessions = vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 }) + const closeSurface = vi.fn() + const killPty = vi.fn() + + const summary = await runKillAllTerminalSurfaces([], { + getState: () => state(), + killDaemonSessions, + closeSurface, + killPty, + reportSummary: vi.fn() + }) + + expect(summary).toMatchObject({ + targetCount: 0, + closeAttemptCount: 0, + absentTargetCount: 0, + exactKillAcceptedCount: 0, + exactKillRejectedCount: 0, + daemon: { status: 'fulfilled', killedCount: 0, remainingCount: 0 } + }) + expect(killDaemonSessions).toHaveBeenCalledTimes(1) + expect(closeSurface).not.toHaveBeenCalled() + expect(killPty).not.toHaveBeenCalled() + }) + + it('uses one daemon management call and never inventories sessions afterward', async () => { + const killAll = vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 }) + const listSessions = vi.fn() + vi.stubGlobal('window', { + api: { + pty: { + kill: vi.fn(), + management: { killAll, listSessions } + } + } + }) + try { + await runKillAllTerminalSurfaces([], { + getState: () => state(), + reportSummary: vi.fn() + }) + + expect(killAll).toHaveBeenCalledTimes(1) + expect(listSessions).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) + + it('settles each close attempt and verifies final absence in both tab stores', async () => { + const current = state({ + tabsByWorktree: { + wt: [terminal('throws-after-close', 'wt'), terminal('remains', 'wt')] + }, + unifiedTabsByWorktree: { + wt: [unified('remains-visible', 'remains', 'wt')] + } + }) + const closeSurface = vi.fn((targetId: string) => { + if (targetId === 'throws-after-close') { + removeSurface(current, targetId) + throw new Error('post-close store failure') + } + }) + + const summary = await runKillAllTerminalSurfaces(['throws-after-close', 'remains'], { + getState: () => current, + killDaemonSessions: vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 }), + closeSurface, + killPty: vi.fn(), + reportSummary: vi.fn() + }) + + expect(closeSurface).toHaveBeenCalledTimes(2) + expect(summary).toMatchObject({ + absentTargetCount: 1, + failedCloseAttemptCount: 2 + }) + }) + + it('records bounded count and latency evidence for 100 terminal tabs', async () => { + const tabs = Array.from({ length: 100 }, (_, index) => terminal(`tab-${index}`, 'wt')) + const ptyIdsByTabId = Object.fromEntries(tabs.map((tab, index) => [tab.id, [`pty-${index}`]])) + const previousState = useAppStore.getState() + useAppStore.setState({ + activeWorktreeId: null, + tabsByWorktree: { wt: tabs }, + unifiedTabsByWorktree: {}, + ptyIdsByTabId + }) + let zustandWrites = 0 + const unsubscribe = useAppStore.subscribe(() => { + zustandWrites += 1 + }) + const killPty = vi.fn().mockResolvedValue(undefined) + try { + const summary = await runKillAllTerminalSurfaces(snapshotKillAllTerminalSurfaceIds(), { + killDaemonSessions: vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 }), + killPty, + reportSummary: vi.fn() + }) + + expect(summary.closeAttemptCount).toBe(100) + expect(summary.absentTargetCount).toBe(100) + expect(summary.exactKillAcceptedCount).toBe(100) + expect(summary.closeDurationMs).toBeGreaterThanOrEqual(0) + expect(summary.maxCloseBatchDurationMs).toBeLessThanOrEqual(50) + expect(summary.closeYieldCount).toBe(49) + expect(summary.closePhaseExceededLongTaskBudget).toBe(false) + expect(zustandWrites).toBeGreaterThanOrEqual(100) + expect(killPty).toHaveBeenCalledTimes(100) + } finally { + unsubscribe() + useAppStore.setState(previousState, true) + } + }) +}) diff --git a/src/renderer/src/components/shared/kill-all-terminal-surfaces.ts b/src/renderer/src/components/shared/kill-all-terminal-surfaces.ts new file mode 100644 index 00000000000..4509b2017a1 --- /dev/null +++ b/src/renderer/src/components/shared/kill-all-terminal-surfaces.ts @@ -0,0 +1,215 @@ +import { useAppStore, type AppState } from '@/store' +import { closeTerminalTab } from '../terminal/terminal-tab-actions' + +const CLOSE_BATCH_SIZE = 2 + +type DaemonKillAllResult = { + killedCount: number + remainingCount: number +} + +export type KillAllTerminalSurfaceState = Pick< + AppState, + 'activeWorktreeId' | 'ptyIdsByTabId' | 'tabsByWorktree' | 'unifiedTabsByWorktree' +> + +export type KillAllTerminalSurfacesSummary = { + targetCount: number + closeAttemptCount: number + absentTargetCount: number + failedCloseAttemptCount: number + exactKillAcceptedCount: number + exactKillRejectedCount: number + closeDurationMs: number + maxCloseBatchDurationMs: number + closeYieldCount: number + closePhaseExceededLongTaskBudget: boolean + daemon: + | ({ status: 'fulfilled' } & DaemonKillAllResult) + | { + status: 'rejected' + } +} + +type KillAllTerminalSurfaceDependencies = { + getState: () => KillAllTerminalSurfaceState + killDaemonSessions: () => Promise + closeSurface: (tabId: string, options: { force: true }) => void + killPty: (ptyId: string) => Promise + now: () => number + yieldToRenderer: () => Promise + reportSummary: (summary: KillAllTerminalSurfacesSummary) => void +} + +export function snapshotKillAllTerminalSurfaceIds( + state: KillAllTerminalSurfaceState = useAppStore.getState() +): string[] { + const targetIds = new Set() + for (const tabs of Object.values(state.tabsByWorktree)) { + for (const tab of tabs) { + targetIds.add(tab.id) + } + } + for (const tabs of Object.values(state.unifiedTabsByWorktree)) { + for (const tab of tabs) { + if (tab.contentType === 'terminal') { + targetIds.add(tab.entityId) + } + } + } + return [...targetIds] +} + +function getTargetOwners( + state: KillAllTerminalSurfaceState, + targetIds: ReadonlySet +): Map { + const ownerByTargetId = new Map() + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + for (const tab of tabs) { + if (targetIds.has(tab.id) && !ownerByTargetId.has(tab.id)) { + ownerByTargetId.set(tab.id, worktreeId) + } + } + } + for (const [worktreeId, tabs] of Object.entries(state.unifiedTabsByWorktree)) { + for (const tab of tabs) { + if ( + tab.contentType === 'terminal' && + targetIds.has(tab.entityId) && + !ownerByTargetId.has(tab.entityId) + ) { + ownerByTargetId.set(tab.entityId, worktreeId) + } + } + } + return ownerByTargetId +} + +function isTargetPresent(state: KillAllTerminalSurfaceState, targetId: string): boolean { + return snapshotKillAllTerminalSurfaceIds(state).includes(targetId) +} + +function createDefaultDependencies(): KillAllTerminalSurfaceDependencies { + return { + getState: useAppStore.getState, + killDaemonSessions: () => window.api.pty.management.killAll(), + closeSurface: closeTerminalTab, + killPty: (ptyId) => window.api.pty.kill(ptyId), + now: () => globalThis.performance?.now() ?? Date.now(), + yieldToRenderer: () => + new Promise((resolve) => { + const channel = new MessageChannel() + channel.port1.onmessage = () => { + channel.port1.close() + channel.port2.close() + resolve() + } + channel.port2.postMessage(undefined) + }), + reportSummary: (summary) => console.info('[kill-all-terminal-surfaces]', summary) + } +} + +export async function runKillAllTerminalSurfaces( + snapshotTargetIds: readonly string[], + dependencies: Partial = {} +): Promise { + const deps = { ...createDefaultDependencies(), ...dependencies } + const targetIds = [...new Set(snapshotTargetIds)] + const targetIdSet = new Set(targetIds) + + let daemon: KillAllTerminalSurfacesSummary['daemon'] + try { + daemon = { status: 'fulfilled', ...(await deps.killDaemonSessions()) } + } catch { + daemon = { status: 'rejected' } + } + + const cleanupState = deps.getState() + const ownerByTargetId = getTargetOwners(cleanupState, targetIdSet) + const presentTargetIds = targetIds.filter((targetId) => ownerByTargetId.has(targetId)) + const activeWorktreeId = cleanupState.activeWorktreeId + // Why: keeping the active worktree until its targets close lets the existing + // tab action choose editor/browser/deactivation without spawning a replacement. + const closeOrder = [ + ...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) !== activeWorktreeId), + ...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) === activeWorktreeId) + ] + + const exactPtyIds = new Set() + for (const targetId of presentTargetIds) { + for (const ptyId of cleanupState.ptyIdsByTabId[targetId] ?? []) { + if (ptyId.length > 0 && !ptyId.startsWith('remote:')) { + exactPtyIds.add(ptyId) + } + } + } + + let failedCloseAttemptCount = 0 + const closeStartedAt = deps.now() + let closeBatchStartedAt = closeStartedAt + let maxCloseBatchDurationMs = 0 + let closeYieldCount = 0 + for (let index = 0; index < closeOrder.length; index += 1) { + const targetId = closeOrder[index] + let failed = false + try { + deps.closeSurface(targetId, { force: true }) + } catch { + failed = true + } + try { + failed ||= isTargetPresent(deps.getState(), targetId) + } catch { + failed = true + } + if (failed) { + failedCloseAttemptCount += 1 + } + const isBatchEnd = (index + 1) % CLOSE_BATCH_SIZE === 0 || index + 1 === closeOrder.length + if (isBatchEnd) { + maxCloseBatchDurationMs = Math.max(maxCloseBatchDurationMs, deps.now() - closeBatchStartedAt) + } + if (isBatchEnd && index + 1 < closeOrder.length) { + // Why: closeTab cascades clone several store maps, so large confirmed + // snapshots yield between bounded batches instead of monopolizing a frame. + try { + await deps.yieldToRenderer() + } catch { + // Yield failure is not cleanup failure; keep closing the confirmed set. + } + closeYieldCount += 1 + closeBatchStartedAt = deps.now() + } + } + const closeDurationMs = Math.max(0, deps.now() - closeStartedAt) + + const exactKillResults = await Promise.allSettled( + [...exactPtyIds].map((ptyId) => Promise.resolve().then(() => deps.killPty(ptyId))) + ) + const exactKillAcceptedCount = exactKillResults.filter( + (result) => result.status === 'fulfilled' + ).length + const finalTargetIds = new Set(snapshotKillAllTerminalSurfaceIds(deps.getState())) + const absentTargetCount = targetIds.filter((targetId) => !finalTargetIds.has(targetId)).length + const summary: KillAllTerminalSurfacesSummary = { + targetCount: targetIds.length, + closeAttemptCount: closeOrder.length, + absentTargetCount, + failedCloseAttemptCount, + exactKillAcceptedCount, + exactKillRejectedCount: exactKillResults.length - exactKillAcceptedCount, + closeDurationMs: Math.round(closeDurationMs * 100) / 100, + maxCloseBatchDurationMs: Math.round(maxCloseBatchDurationMs * 100) / 100, + closeYieldCount, + closePhaseExceededLongTaskBudget: maxCloseBatchDurationMs > 50, + daemon + } + try { + deps.reportSummary(summary) + } catch { + // Diagnostics must not change the already-settled destructive action. + } + return summary +} diff --git a/src/renderer/src/components/shared/useDaemonActions.test.tsx b/src/renderer/src/components/shared/useDaemonActions.test.tsx new file mode 100644 index 00000000000..6c9e00b8f73 --- /dev/null +++ b/src/renderer/src/components/shared/useDaemonActions.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { runCleanupMock, snapshotMock, toastErrorMock, toastInfoMock, toastSuccessMock } = + vi.hoisted(() => ({ + runCleanupMock: vi.fn(), + snapshotMock: vi.fn(), + toastErrorMock: vi.fn(), + toastInfoMock: vi.fn(), + toastSuccessMock: vi.fn() + })) + +vi.mock('./kill-all-terminal-surfaces', () => ({ + runKillAllTerminalSurfaces: runCleanupMock, + snapshotKillAllTerminalSurfaceIds: snapshotMock +})) + +vi.mock('sonner', () => ({ + toast: { + error: toastErrorMock, + info: toastInfoMock, + success: toastSuccessMock, + warning: vi.fn() + } +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +import type { KillAllTerminalSurfacesSummary } from './kill-all-terminal-surfaces' +import { useDaemonActions } from './useDaemonActions' + +function rejectedSummary(): KillAllTerminalSurfacesSummary { + return { + targetCount: 1, + closeAttemptCount: 1, + absentTargetCount: 1, + failedCloseAttemptCount: 0, + exactKillAcceptedCount: 1, + exactKillRejectedCount: 0, + closeDurationMs: 1, + maxCloseBatchDurationMs: 1, + closeYieldCount: 0, + closePhaseExceededLongTaskBudget: false, + daemon: { status: 'rejected' } + } +} + +function successfulSurfaceSummary(): KillAllTerminalSurfacesSummary { + return { + targetCount: 1, + closeAttemptCount: 1, + absentTargetCount: 1, + failedCloseAttemptCount: 0, + exactKillAcceptedCount: 0, + exactKillRejectedCount: 0, + closeDurationMs: 1, + maxCloseBatchDurationMs: 1, + closeYieldCount: 0, + closePhaseExceededLongTaskBudget: false, + daemon: { status: 'fulfilled', killedCount: 0, remainingCount: 0 } + } +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +describe('useDaemonActions kill-all cleanup', () => { + beforeEach(() => { + vi.clearAllMocks() + snapshotMock.mockReturnValue(['confirmed-tab']) + }) + + it('snapshots before start and does not revoke cleanup when the caller unmounts', async () => { + const cleanup = deferred() + const sequence: string[] = [] + const onKillAllStart = vi.fn(() => sequence.push('start')) + const onKillAllError = vi.fn() + const onKillAllSettled = vi.fn() + snapshotMock.mockImplementation(() => { + sequence.push('snapshot') + return ['confirmed-tab'] + }) + runCleanupMock.mockImplementation(() => { + sequence.push('cleanup') + return cleanup.promise + }) + const { result, unmount } = renderHook(() => + useDaemonActions({ onKillAllStart, onKillAllError, onKillAllSettled }) + ) + + let completion!: Promise + act(() => { + completion = result.current.runKillAll() + }) + + expect(sequence).toEqual(['snapshot', 'start', 'cleanup']) + expect(runCleanupMock).toHaveBeenCalledWith(['confirmed-tab']) + unmount() + cleanup.resolve(rejectedSummary()) + await completion + + expect(toastErrorMock).toHaveBeenCalledTimes(1) + expect(onKillAllError).not.toHaveBeenCalled() + expect(onKillAllSettled).not.toHaveBeenCalled() + }) + + it('runs mounted error and settled callbacks only after cleanup settlement', async () => { + const cleanup = deferred() + const onKillAllError = vi.fn() + const onKillAllSettled = vi.fn() + runCleanupMock.mockReturnValue(cleanup.promise) + const { result } = renderHook(() => useDaemonActions({ onKillAllError, onKillAllSettled })) + + let completion!: Promise + act(() => { + completion = result.current.runKillAll() + }) + expect(onKillAllError).not.toHaveBeenCalled() + expect(onKillAllSettled).not.toHaveBeenCalled() + + await act(async () => { + cleanup.resolve(rejectedSummary()) + await completion + }) + + expect(onKillAllError).toHaveBeenCalledTimes(1) + expect(onKillAllSettled).toHaveBeenCalledTimes(1) + }) + + it('reports closed terminal tabs as success when daemon management found no sessions', async () => { + runCleanupMock.mockResolvedValue(successfulSurfaceSummary()) + const { result } = renderHook(() => useDaemonActions()) + + await act(async () => { + await result.current.runKillAll() + }) + + expect(toastSuccessMock).toHaveBeenCalledWith( + 'Terminal tabs closed and shutdown requested.', + expect.any(Object) + ) + expect(toastInfoMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/shared/useDaemonActions.tsx b/src/renderer/src/components/shared/useDaemonActions.tsx index 9ae55cad0b1..8c386b5ef13 100644 --- a/src/renderer/src/components/shared/useDaemonActions.tsx +++ b/src/renderer/src/components/shared/useDaemonActions.tsx @@ -12,6 +12,11 @@ import { DialogTitle } from '../ui/dialog' import { translate } from '@/i18n/i18n' +import { + runKillAllTerminalSurfaces, + snapshotKillAllTerminalSurfaceIds, + type KillAllTerminalSurfacesSummary +} from './kill-all-terminal-surfaces' export type DaemonActionKind = 'restart' | 'killAll' @@ -38,6 +43,90 @@ export type DaemonActionsApi = { runConfirmed: () => void } +function showKillAllTerminalSurfacesResult(summary: KillAllTerminalSurfacesSummary): void { + const surfaceDescription = + summary.targetCount > 0 + ? translate( + 'auto.components.shared.useDaemonActions.71a8d342b0', + 'Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.', + { + value0: summary.absentTargetCount, + value1: summary.targetCount, + value2: summary.failedCloseAttemptCount, + value3: summary.exactKillAcceptedCount, + value4: summary.exactKillRejectedCount + } + ) + : null + const daemonDescription = + summary.daemon.status === 'rejected' + ? translate( + 'auto.components.shared.useDaemonActions.2e57c1a940', + 'The daemon shutdown result is unverified because its management request failed.' + ) + : translate( + 'auto.components.shared.useDaemonActions.993af6052c', + 'Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.', + { + value0: summary.daemon.killedCount, + value1: summary.daemon.killedCount + summary.daemon.remainingCount, + value2: summary.daemon.remainingCount + } + ) + const description = [surfaceDescription, daemonDescription].filter(Boolean).join(' ') + if (summary.daemon.status === 'rejected') { + toast.error( + translate( + 'auto.components.shared.useDaemonActions.1f0d8ac762', + 'Terminal cleanup finished with errors.' + ), + { description } + ) + return + } + if (summary.failedCloseAttemptCount > 0 || summary.exactKillRejectedCount > 0) { + toast.error( + translate( + 'auto.components.shared.useDaemonActions.1f0d8ac762', + 'Terminal cleanup finished with errors.' + ), + { description } + ) + return + } + if (summary.daemon.remainingCount > 0) { + toast.warning( + translate( + 'auto.components.shared.useDaemonActions.80b6ea14cf', + 'Terminal cleanup finished with warnings.' + ), + { description } + ) + return + } + if (summary.targetCount === 0 && summary.daemon.killedCount === 0) { + toast.info( + translate( + 'auto.components.shared.useDaemonActions.47cd2a50e9', + 'No sessions or terminal tabs were reported.' + ) + ) + return + } + toast.success( + summary.targetCount > 0 + ? translate( + 'auto.components.shared.useDaemonActions.c34fb1098d', + 'Terminal tabs closed and shutdown requested.' + ) + : translate( + 'auto.components.shared.useDaemonActions.d9657ac204', + 'Terminal session shutdown requested.' + ), + { description } + ) +} + export function useDaemonActions(callbacks?: DaemonActionCallbacks): DaemonActionsApi { const [pending, setPending] = useState(null) const [busyKind, setBusyKind] = useState(null) @@ -83,61 +172,26 @@ export function useDaemonActions(callbacks?: DaemonActionCallbacks): DaemonActio }, [callbacks, clearPendingAction, mountedRef]) const runKillAll = useCallback(async () => { + // Why: confirmation covers the surface identities visible now; taking this + // before callbacks or awaits keeps later-created terminal tabs out of scope. + const targetSurfaceIds = snapshotKillAllTerminalSurfaceIds() setBusyKind('killAll') callbacks?.onKillAllStart?.() try { - const { killedCount, remainingCount } = await window.api.pty.management.killAll() - if (remainingCount > 0 && killedCount > 0) { - toast.warning( - translate( - 'auto.components.shared.useDaemonActions.fe2ab66d45', - 'Killed {{value0}} of {{value1}} sessions. {{value2}} refused to exit.', - { value0: killedCount, value1: killedCount + remainingCount, value2: remainingCount } - ) - ) - } else if (killedCount === 1) { - toast.success( - translate( - 'auto.components.shared.useDaemonActions.87412c2a68', - 'Killed {{value0}} session.', - { value0: killedCount } - ) - ) - } else if (killedCount > 0) { - toast.success( - translate( - 'auto.components.shared.useDaemonActions.a2f040ac1c', - 'Killed {{value0}} sessions.', - { value0: killedCount } - ) - ) - } else if (remainingCount === 0) { - toast.info( - translate('auto.components.shared.useDaemonActions.baad8cd651', 'No sessions running.') - ) - } else if (remainingCount === 1) { - toast.error( - translate( - 'auto.components.shared.useDaemonActions.63520148e2', - '{{value0}} session refused to exit.', - { value0: remainingCount } - ) - ) - } else { - toast.error( - translate( - 'auto.components.shared.useDaemonActions.cc0a26cb14', - '{{value0}} sessions refused to exit.', - { value0: remainingCount } - ) - ) + const summary = await runKillAllTerminalSurfaces(targetSurfaceIds) + if (summary.daemon.status === 'rejected' && mountedRef.current) { + callbacks?.onKillAllError?.() } + showKillAllTerminalSurfacesResult(summary) } catch (err) { if (mountedRef.current) { callbacks?.onKillAllError?.() } toast.error( - translate('auto.components.shared.useDaemonActions.2b4efdc162', 'Couldn’t kill sessions.'), + translate( + 'auto.components.shared.useDaemonActions.e8f25bd903', + 'Couldn’t finish terminal cleanup.' + ), { description: err instanceof Error ? err.message : undefined } @@ -203,8 +257,8 @@ function getCopy(kind: DaemonActionKind): CopyShape { description: ( <> {translate( - 'auto.components.shared.useDaemonActions.28c8e53176', - "This force-quits every running terminal pane across all workspaces. Any unsaved work in those sessions is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone." + 'auto.components.shared.useDaemonActions.a702d4196e', + "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone." )} ), diff --git a/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts new file mode 100644 index 00000000000..a8ec093c60d --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getStateMock, closeWebRuntimeSessionTabMock, isWebRuntimeSessionActiveMock } = vi.hoisted( + () => ({ + getStateMock: vi.fn(), + closeWebRuntimeSessionTabMock: vi.fn(), + isWebRuntimeSessionActiveMock: vi.fn(() => false) + }) +) + +vi.mock('@/store', () => ({ + useAppStore: { getState: getStateMock } +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + activateWebRuntimeSessionTab: vi.fn(), + closeWebRuntimeSessionTab: closeWebRuntimeSessionTabMock, + createWebRuntimeSessionTerminal: vi.fn(), + isWebRuntimeSessionActive: isWebRuntimeSessionActiveMock, + toHostSessionTabId: vi.fn((tabId: string) => tabId) +})) + +vi.mock('@/runtime/web-session-tabs-sync', () => ({ + resolveHostSessionTabIdForWebSessionTab: vi.fn(() => null) +})) + +import { closeTerminalTab } from './terminal-tab-actions' + +function baseState(overrides: Record = {}): Record { + return { + settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: true }, + repos: [], + worktreesByRepo: {}, + tabsByWorktree: { wt: [{ id: 'terminal-1' }] }, + unifiedTabsByWorktree: {}, + activeWorktreeId: 'wt', + activeTabId: 'terminal-1', + openFiles: [], + browserTabsByWorktree: {}, + closeTab: vi.fn(), + closeUnifiedTab: vi.fn(), + setActiveFile: vi.fn(), + setActiveBrowserTab: vi.fn(), + setActiveTabType: vi.fn(), + setActiveTab: vi.fn(), + setActiveWorktree: vi.fn(), + requestPinnedTabCloseConfirm: vi.fn(), + createTab: vi.fn(), + closeFile: vi.fn(), + closeBrowserTab: vi.fn(), + ...overrides + } +} + +describe('closeTerminalTab kill-all routing', () => { + beforeEach(() => { + vi.clearAllMocks() + isWebRuntimeSessionActiveMock.mockReturnValue(false) + }) + + it('force-closes a pinned terminal without opening a second confirmation', () => { + const closeUnifiedTab = vi.fn() + const requestPinnedTabCloseConfirm = vi.fn() + getStateMock.mockReturnValue( + baseState({ + tabsByWorktree: {}, + unifiedTabsByWorktree: { + wt: [ + { + id: 'visible-pinned', + entityId: 'terminal-1', + contentType: 'terminal', + isPinned: true + } + ] + }, + closeUnifiedTab, + requestPinnedTabCloseConfirm + }) + ) + + closeTerminalTab('terminal-1', { force: true }) + + expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled() + expect(closeUnifiedTab).toHaveBeenCalledWith('visible-pinned') + }) + + it('routes the last active terminal to an existing editor without closing it', () => { + const state = baseState({ + openFiles: [{ id: 'editor-1', worktreeId: 'wt' }] + }) + getStateMock.mockReturnValue(state) + + closeTerminalTab('terminal-1', { force: true }) + + expect(state.closeTab).toHaveBeenCalledWith('terminal-1') + expect(state.setActiveFile).toHaveBeenCalledWith('editor-1') + expect(state.setActiveTabType).toHaveBeenCalledWith('editor') + expect(state.closeFile).not.toHaveBeenCalled() + expect(state.closeBrowserTab).not.toHaveBeenCalled() + expect(state.setActiveWorktree).not.toHaveBeenCalled() + expect(state.createTab).not.toHaveBeenCalled() + }) + + it('routes the last active terminal to an existing browser when no editor exists', () => { + const state = baseState({ + browserTabsByWorktree: { wt: [{ id: 'browser-1' }] } + }) + getStateMock.mockReturnValue(state) + + closeTerminalTab('terminal-1', { force: true }) + + expect(state.setActiveBrowserTab).toHaveBeenCalledWith('browser-1') + expect(state.setActiveTabType).toHaveBeenCalledWith('browser') + expect(state.closeBrowserTab).not.toHaveBeenCalled() + expect(state.setActiveWorktree).not.toHaveBeenCalled() + expect(state.createTab).not.toHaveBeenCalled() + }) + + it('deactivates after the last active terminal when no other content exists', () => { + const state = baseState() + getStateMock.mockReturnValue(state) + + closeTerminalTab('terminal-1', { force: true }) + + expect(state.setActiveWorktree).toHaveBeenCalledWith(null) + expect(state.setActiveFile).not.toHaveBeenCalled() + expect(state.setActiveBrowserTab).not.toHaveBeenCalled() + expect(state.createTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a4f1095d876..7c539293378 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4645,7 +4645,17 @@ "87412c2a68": "Killed {{value0}} session.", "a2f040ac1c": "Killed {{value0}} sessions.", "63520148e2": "{{value0}} session refused to exit.", - "cc0a26cb14": "{{value0}} sessions refused to exit." + "cc0a26cb14": "{{value0}} sessions refused to exit.", + "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", + "2e57c1a940": "The daemon shutdown result is unverified because its management request failed.", + "993af6052c": "Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.", + "1f0d8ac762": "Terminal cleanup finished with errors.", + "80b6ea14cf": "Terminal cleanup finished with warnings.", + "47cd2a50e9": "No sessions or terminal tabs were reported.", + "c34fb1098d": "Terminal tabs closed and shutdown requested.", + "d9657ac204": "Terminal session shutdown requested.", + "e8f25bd903": "Couldn’t finish terminal cleanup.", + "a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone." } }, "setup": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9485e8f9568..5aca9e27d2f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4645,7 +4645,17 @@ "87412c2a68": "Se terminó {{value0}} sesión.", "a2f040ac1c": "Se terminaron {{value0}} sesiones.", "63520148e2": "La sesión {{value0}} se negó a salir.", - "cc0a26cb14": "{{value0}} sesiones se negaron a salir." + "cc0a26cb14": "{{value0}} sesiones se negaron a salir.", + "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", + "2e57c1a940": "The daemon shutdown result is unverified because its management request failed.", + "993af6052c": "Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.", + "1f0d8ac762": "Terminal cleanup finished with errors.", + "80b6ea14cf": "Terminal cleanup finished with warnings.", + "47cd2a50e9": "No sessions or terminal tabs were reported.", + "c34fb1098d": "Terminal tabs closed and shutdown requested.", + "d9657ac204": "Terminal session shutdown requested.", + "e8f25bd903": "Couldn’t finish terminal cleanup.", + "a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone." } }, "setup": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index bf957a82d0d..5bbff91e81e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4645,7 +4645,17 @@ "87412c2a68": "{{value0}} 個のセッションを終了しました。", "a2f040ac1c": "{{value0}} 個のセッションを終了しました。", "63520148e2": "{{value0}} 個のセッションが終了を拒否しました。", - "cc0a26cb14": "{{value0}} 個のセッションが終了を拒否しました。" + "cc0a26cb14": "{{value0}} 個のセッションが終了を拒否しました。", + "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", + "2e57c1a940": "The daemon shutdown result is unverified because its management request failed.", + "993af6052c": "Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.", + "1f0d8ac762": "Terminal cleanup finished with errors.", + "80b6ea14cf": "Terminal cleanup finished with warnings.", + "47cd2a50e9": "No sessions or terminal tabs were reported.", + "c34fb1098d": "Terminal tabs closed and shutdown requested.", + "d9657ac204": "Terminal session shutdown requested.", + "e8f25bd903": "Couldn’t finish terminal cleanup.", + "a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone." } }, "setup": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f362d26f46d..7fd7ae2323e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4645,7 +4645,17 @@ "87412c2a68": "{{value0}}개 세션을 종료했습니다.", "a2f040ac1c": "{{value0}}개 세션을 종료했습니다.", "63520148e2": "{{value0}}개 세션이 종료를 거부했습니다.", - "cc0a26cb14": "{{value0}}개 세션이 종료를 거부했습니다." + "cc0a26cb14": "{{value0}}개 세션이 종료를 거부했습니다.", + "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", + "2e57c1a940": "The daemon shutdown result is unverified because its management request failed.", + "993af6052c": "Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.", + "1f0d8ac762": "Terminal cleanup finished with errors.", + "80b6ea14cf": "Terminal cleanup finished with warnings.", + "47cd2a50e9": "No sessions or terminal tabs were reported.", + "c34fb1098d": "Terminal tabs closed and shutdown requested.", + "d9657ac204": "Terminal session shutdown requested.", + "e8f25bd903": "Couldn’t finish terminal cleanup.", + "a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone." } }, "setup": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index be29a053cc0..d8d77d71fc6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4645,7 +4645,17 @@ "87412c2a68": "已终止 {{value0}} 个会话。", "a2f040ac1c": "已终止 {{value0}} 个会话。", "63520148e2": "{{value0}} 个会话拒绝退出。", - "cc0a26cb14": "{{value0}} 个会话拒绝退出。" + "cc0a26cb14": "{{value0}} 个会话拒绝退出。", + "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", + "2e57c1a940": "The daemon shutdown result is unverified because its management request failed.", + "993af6052c": "Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.", + "1f0d8ac762": "Terminal cleanup finished with errors.", + "80b6ea14cf": "Terminal cleanup finished with warnings.", + "47cd2a50e9": "No sessions or terminal tabs were reported.", + "c34fb1098d": "Terminal tabs closed and shutdown requested.", + "d9657ac204": "Terminal session shutdown requested.", + "e8f25bd903": "Couldn’t finish terminal cleanup.", + "a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone." } }, "setup": { From 1392981cd73d80e56796034d34a733feefcb19c1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:45:05 -0700 Subject: [PATCH 15/17] Show dispatch task preview and refresh worker tab titles (#8024) * Show dispatch task preview and refresh generated titles when orchestrati - Worker panes now display the task block from the dispatch preamble instead of the raw prompt until orchestration metadata resolves - Once orchestration displayName/taskTitle arrives, replace the previously generated tab title instead of leaving the stale one * fix: harden dispatch title preview and replace paths Bound preamble scans, match orchestration labels to live task IDs, and skip expensive title derivation when auto-title will not write. * fix: accept orchestration labels when live task id is unparseable Agent-status prompts are often truncated and omit the task-id line. Only reject sticky labels when both ids are present and disagree. --- .../src/lib/agent-row-primary-text.test.ts | 82 ++++++- .../src/lib/agent-row-primary-text.ts | 110 ++++++++- .../slices/agent-generated-tab-title.test.ts | 209 ++++++++++++++++++ src/renderer/src/store/slices/agent-status.ts | 107 ++++++++- src/renderer/src/store/slices/terminals.ts | 62 ++++-- 5 files changed, 547 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/lib/agent-row-primary-text.test.ts b/src/renderer/src/lib/agent-row-primary-text.test.ts index 49d3237e1fa..b0360588c59 100644 --- a/src/renderer/src/lib/agent-row-primary-text.test.ts +++ b/src/renderer/src/lib/agent-row-primary-text.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { getAgentRowPrimaryText } from './agent-row-primary-text' +import { + getAgentRowGeneratedTitleText, + getAgentRowPrimaryText, + isOrcaDispatchPrompt +} from './agent-row-primary-text' describe('getAgentRowPrimaryText', () => { it('prefers orchestration display name over the raw hook prompt', () => { @@ -29,7 +33,83 @@ describe('getAgentRowPrimaryText', () => { ).toBe('Checkout race') }) + it('ignores sticky orchestration labels that belong to a different task id', () => { + expect( + getAgentRowPrimaryText({ + prompt: `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your task ID is: task_2 + +=== TASK === +Review dispatch prompts and make worker labels distinct`, + orchestration: { + taskId: 'task_1', + dispatchId: 'ctx-1', + taskTitle: 'Stale task', + displayName: 'Stale worker label' + } + }) + ).toBe('Review dispatch prompts and make worker labels distinct') + }) + + it('uses the task block when orchestration metadata has not arrived yet', () => { + expect( + getAgentRowPrimaryText({ + prompt: `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your coordinator's terminal handle is: term_parent +Your task ID is: task_1 + +=== CLI COMMANDS === +orca orchestration send --to term_parent + +=== TASK === +Review dispatch prompts and make worker labels distinct + +Keep the raw preamble out of the sidebar.` + }) + ).toBe('Review dispatch prompts and make worker labels distinct') + }) + it('falls back to the raw prompt outside orchestration workers', () => { expect(getAgentRowPrimaryText({ prompt: 'Fix checkout race' })).toBe('Fix checkout race') }) }) + +describe('isOrcaDispatchPrompt / getAgentRowGeneratedTitleText', () => { + it('treats leading whitespace as still a dispatch preamble', () => { + expect( + isOrcaDispatchPrompt(' You are working inside Orca, a multi-agent IDE. Worker task') + ).toBe(true) + }) + + it('uses orchestration labels for generated titles only on matching dispatch prompts', () => { + expect( + getAgentRowGeneratedTitleText({ + prompt: `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your task ID is: task-1 + +=== TASK === +Checkout race body`, + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + taskTitle: 'Checkout race', + displayName: 'Fix checkout race' + } + }) + ).toBe('Fix checkout race') + }) + + it('ignores sticky orchestration for non-dispatch generated titles', () => { + expect( + getAgentRowGeneratedTitleText({ + prompt: 'Refactor the auth middleware', + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + taskTitle: 'Stale task', + displayName: 'Stale worker label' + } + }) + ).toBe('Refactor the auth middleware') + }) +}) diff --git a/src/renderer/src/lib/agent-row-primary-text.ts b/src/renderer/src/lib/agent-row-primary-text.ts index b4135f8fb80..931dd1fbbdc 100644 --- a/src/renderer/src/lib/agent-row-primary-text.ts +++ b/src/renderer/src/lib/agent-row-primary-text.ts @@ -1,11 +1,115 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types' +export const ORCA_DISPATCH_PREAMBLE_PREFIX = 'You are working inside Orca, a multi-agent IDE.' +const ORCA_DISPATCH_TASK_MARKER = '=== TASK ===' +const ORCA_DISPATCH_TASK_ID_MARKER = 'Your task ID is:' +// Why: match deriveGeneratedTabTitle's scan budget — previews only need the +// first non-empty task line, not the rest of a paste-sized worker prompt. +const ORCA_DISPATCH_TASK_PREVIEW_SCAN_LIMIT = 512 +// Why: task id lives near the top of the preamble; keep that scan tight. +const ORCA_DISPATCH_TASK_ID_SCAN_LIMIT = 1024 +// Why: === TASK === sits after CLI instructions (a few KB). Cap the search so a +// malformed multi-MB prompt without a marker never full-scans the task body. +const ORCA_DISPATCH_TASK_MARKER_SCAN_LIMIT = 32_768 + +/** True when the live prompt is still an Orca dispatch turn (not sticky metadata alone). */ +export function isOrcaDispatchPrompt(prompt: string): boolean { + return prompt.trimStart().startsWith(ORCA_DISPATCH_PREAMBLE_PREFIX) +} + +/** + * True when orchestration labels may label the live dispatch turn. + * Reject only when both sides expose a taskId and they differ — sticky completed + * metadata must not rename a later dispatch. When the live prompt is truncated + * (agent-status fields are short) and has no parseable taskId, trust labels. + */ +export function orchestrationLabelsMatchLiveDispatch( + entry: Pick +): boolean { + if (!isOrcaDispatchPrompt(entry.prompt)) { + return false + } + const orchestrationTaskId = entry.orchestration?.taskId?.trim() + if (!orchestrationTaskId) { + return false + } + const liveTaskId = getOrcaDispatchTaskId(entry.prompt) + if (!liveTaskId) { + return true + } + return liveTaskId === orchestrationTaskId +} + export function getAgentRowPrimaryText( entry: Pick ): string { + if (orchestrationLabelsMatchLiveDispatch(entry)) { + return ( + entry.orchestration?.displayName?.trim() || + entry.orchestration?.taskTitle?.trim() || + getOrcaDispatchTaskPreview(entry.prompt) || + // Why: non-dispatch and unmatched paths keep a full trim for display; this + // branch only runs for matched dispatch turns, where a bounded preview + // almost always succeeds first. + entry.prompt.trimStart().slice(0, ORCA_DISPATCH_TASK_PREVIEW_SCAN_LIMIT).trim() + ) + } return ( - entry.orchestration?.displayName?.trim() || - entry.orchestration?.taskTitle?.trim() || - entry.prompt.trim() + getOrcaDispatchTaskPreview(entry.prompt) || + // Why: ordinary prompts stay small; dispatch prompts without a TASK marker + // still must not full-trim a multi-MB body for a sidebar label. + (isOrcaDispatchPrompt(entry.prompt) + ? entry.prompt.trimStart().slice(0, ORCA_DISPATCH_TASK_PREVIEW_SCAN_LIMIT).trim() + : entry.prompt.trim()) ) } + +export function getAgentRowGeneratedTitleText( + entry: Pick +): string { + // Why: only prefer orchestration/task labels while the live prompt is still + // the same dispatch turn — sticky orchestration must not rename new work. + if (isOrcaDispatchPrompt(entry.prompt)) { + return getAgentRowPrimaryText(entry) + } + return entry.prompt +} + +export function getOrcaDispatchTaskId(prompt: string): string | null { + if (!isOrcaDispatchPrompt(prompt)) { + return null + } + const scan = prompt.trimStart().slice(0, ORCA_DISPATCH_TASK_ID_SCAN_LIMIT) + const markerIndex = scan.indexOf(ORCA_DISPATCH_TASK_ID_MARKER) + if (markerIndex === -1) { + return null + } + const afterMarker = scan.slice(markerIndex + ORCA_DISPATCH_TASK_ID_MARKER.length) + const lineEnd = afterMarker.search(/\r|\n/) + const idLine = (lineEnd === -1 ? afterMarker : afterMarker.slice(0, lineEnd)).trim() + return idLine || null +} + +function getOrcaDispatchTaskPreview(prompt: string): string { + // Why: sidebar rows call this during render; never full-trim/split paste-sized + // dispatch prompts — only scan bounded windows for the marker and first line. + if (!isOrcaDispatchPrompt(prompt)) { + return '' + } + const scan = prompt + .trimStart() + .slice(0, ORCA_DISPATCH_TASK_MARKER_SCAN_LIMIT + ORCA_DISPATCH_TASK_PREVIEW_SCAN_LIMIT) + const taskMarkerIndex = scan.indexOf(ORCA_DISPATCH_TASK_MARKER) + if (taskMarkerIndex === -1) { + return '' + } + const taskBodyStart = taskMarkerIndex + ORCA_DISPATCH_TASK_MARKER.length + const taskBody = scan.slice(taskBodyStart, taskBodyStart + ORCA_DISPATCH_TASK_PREVIEW_SCAN_LIMIT) + for (const line of taskBody.split(/\r?\n/)) { + const preview = line.trim().replace(/\s+/g, ' ') + if (preview) { + return preview + } + } + return '' +} diff --git a/src/renderer/src/store/slices/agent-generated-tab-title.test.ts b/src/renderer/src/store/slices/agent-generated-tab-title.test.ts index 598ba5dc568..16a5efe0d6a 100644 --- a/src/renderer/src/store/slices/agent-generated-tab-title.test.ts +++ b/src/renderer/src/store/slices/agent-generated-tab-title.test.ts @@ -65,6 +65,215 @@ describe('generated agent tab titles', () => { ) }) + it('replaces a raw dispatch preamble title when orchestration display metadata arrives', () => { + vi.useFakeTimers() + const store = createTestStore() + const tabId = seedWorktree(store, true) + const paneKey = makePaneKey(tabId, LEAF_ID) + + store.getState().setAgentStatus(paneKey, { + state: 'working', + prompt: `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your task ID is: task-1 + +=== CLI COMMANDS === +orca orchestration send --to term_parent + +=== TASK === +Implement the detailed worker instructions that should not be the short label`, + agentType: 'codex' + }) + + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe( + 'Implement the detailed worker' + ) + + store.getState().setRuntimeAgentOrchestrationByPaneKey({ + [paneKey]: { + taskId: 'task-1', + dispatchId: 'ctx-1', + taskTitle: 'Implement worker instructions', + displayName: 'Better worker label' + } + }) + + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe( + 'Better worker label' + ) + expect(store.getState().unifiedTabsByWorktree[WORKTREE_ID][0].generatedLabel).toBe( + 'Better worker label' + ) + }) + + it('does not replace with sticky orchestration when a new non-dispatch prompt arrives', () => { + vi.useFakeTimers() + const store = createTestStore() + const tabId = seedWorktree(store, true) + const paneKey = makePaneKey(tabId, LEAF_ID) + const dispatchPrompt = `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your task ID is: task-1 + +=== CLI COMMANDS === +orca orchestration send --to term_parent + +=== TASK === +Implement the detailed worker instructions that should not be the short label` + + store.getState().setAgentStatus(paneKey, { + state: 'working', + prompt: dispatchPrompt, + agentType: 'codex' + }) + store.getState().setRuntimeAgentOrchestrationByPaneKey({ + [paneKey]: { + taskId: 'task-1', + dispatchId: 'ctx-1', + taskTitle: 'Implement worker instructions', + displayName: 'Better worker label' + } + }) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe( + 'Better worker label' + ) + + // Why: orchestration metadata is sticky (~30m). A later non-dispatch turn on + // the same pane must first-write-wins — not re-assert the old dispatch name. + store.getState().setAgentStatus(paneKey, { + state: 'working', + prompt: 'Refactor the auth middleware to use JWT tokens for session recovery', + agentType: 'codex' + }) + + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe( + 'Better worker label' + ) + }) + + it('generates from a new non-dispatch prompt when sticky orchestration remains but no title exists', () => { + vi.useFakeTimers() + const store = createTestStore() + const tabId = seedWorktree(store, true) + const paneKey = makePaneKey(tabId, LEAF_ID) + + // Seed sticky orchestration without a prior generated title (e.g. feature was off). + store.getState().setAgentStatus(paneKey, { + state: 'done', + prompt: `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your task ID is: task-1 + +=== TASK === +Old dispatch task that already finished`, + agentType: 'codex' + }) + store.getState().setRuntimeAgentOrchestrationByPaneKey({ + [paneKey]: { + taskId: 'task-1', + dispatchId: 'ctx-1', + taskTitle: 'Old dispatch task', + displayName: 'Stale worker label' + } + }) + // Clear any title set during the dispatch turn so the next non-dispatch + // prompt is a pure first-write with sticky orchestration still present. + const tabs = store.getState().tabsByWorktree[WORKTREE_ID] + store.setState({ + tabsByWorktree: { + ...store.getState().tabsByWorktree, + [WORKTREE_ID]: tabs.map((tab) => + tab.id === tabId ? { ...tab, generatedTitle: undefined } : tab + ) + }, + unifiedTabsByWorktree: { + ...store.getState().unifiedTabsByWorktree, + [WORKTREE_ID]: (store.getState().unifiedTabsByWorktree[WORKTREE_ID] ?? []).map((tab) => + tab.id === tabId ? { ...tab, generatedLabel: undefined } : tab + ) + } + }) + + store.getState().setAgentStatus(paneKey, { + state: 'working', + prompt: 'Can you please refactor the auth middleware to use JWT tokens?', + agentType: 'codex' + }) + + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe( + 'Refactor the auth middleware to use JWT' + ) + expect(store.getState().unifiedTabsByWorktree[WORKTREE_ID][0].generatedLabel).toBe( + 'Refactor the auth middleware to use JWT' + ) + }) + + it('does not re-pin sticky task A labels onto a later dispatch task B preamble', () => { + vi.useFakeTimers() + const store = createTestStore() + const tabId = seedWorktree(store, true) + const paneKey = makePaneKey(tabId, LEAF_ID) + + store.getState().setAgentStatus(paneKey, { + state: 'working', + prompt: `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your task ID is: task-a + +=== TASK === +Implement task A worker instructions that should not stick`, + agentType: 'codex' + }) + store.getState().setRuntimeAgentOrchestrationByPaneKey({ + [paneKey]: { + taskId: 'task-a', + dispatchId: 'ctx-a', + taskTitle: 'Task A', + displayName: 'Worker A label' + } + }) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe('Worker A label') + + store.getState().setAgentStatus(paneKey, { + state: 'working', + prompt: `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your task ID is: task-b + +=== TASK === +Implement task B worker instructions for the next dispatch`, + agentType: 'codex' + }) + + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe( + 'Implement task B worker instructions' + ) + }) + + it('does not force-replace titles when sticky orchestration updates after a non-dispatch prompt', () => { + vi.useFakeTimers() + const store = createTestStore() + const tabId = seedWorktree(store, true) + const paneKey = makePaneKey(tabId, LEAF_ID) + + store.getState().setAgentStatus(paneKey, { + state: 'working', + prompt: 'Can you please refactor the auth middleware to use JWT tokens?', + agentType: 'codex' + }) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe( + 'Refactor the auth middleware to use JWT' + ) + + store.getState().setRuntimeAgentOrchestrationByPaneKey({ + [paneKey]: { + taskId: 'task-1', + dispatchId: 'ctx-1', + taskTitle: 'Stale orchestration task', + displayName: 'Stale orchestration label' + } + }) + + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe( + 'Refactor the auth middleware to use JWT' + ) + }) + it('does not trim the full paste-sized prompt before generating an optional title', () => { vi.useFakeTimers() const trimSpy = vi.spyOn(String.prototype, 'trim') diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 06b611a8b7f..182d113411d 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -24,6 +24,12 @@ import { } from '../../../../shared/agent-status-identity' import type { TerminalTab } from '../../../../shared/types' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { + getAgentRowGeneratedTitleText, + getOrcaDispatchTaskId, + isOrcaDispatchPrompt, + orchestrationLabelsMatchLiveDispatch +} from '@/lib/agent-row-primary-text' import { createFreshnessScheduler } from './agent-status-freshness-scheduler' /** Snapshot of a finished (or vanished) agent status entry, kept around so @@ -263,6 +269,34 @@ function getTabIdFromPaneKey(paneKey: string): string | null { return paneKey.slice(0, separator) } +/** True when auto-title generation would no-op without replace (custom/quick/generated). */ +function agentStatusTabAlreadyHasProtectedOrGeneratedTitle( + state: AppState, + tabId: string | null, + worktreeId?: string | null +): boolean { + if (!tabId) { + return false + } + const ownerTabs = worktreeId ? state.tabsByWorktree[worktreeId] : undefined + if (ownerTabs) { + const tab = ownerTabs.find((candidate) => candidate.id === tabId) + return Boolean( + tab?.customTitle?.trim() || tab?.quickCommandLabel?.trim() || tab?.generatedTitle?.trim() + ) + } + for (const tabs of Object.values(state.tabsByWorktree)) { + const tab = tabs.find((candidate) => candidate.id === tabId) + if (!tab) { + continue + } + return Boolean( + tab.customTitle?.trim() || tab.quickCommandLabel?.trim() || tab.generatedTitle?.trim() + ) + } + return false +} + function getLeafIdFromPaneKey(paneKey: string): string | null { const separator = paneKey.indexOf(':') if (separator <= 0 || separator !== paneKey.lastIndexOf(':')) { @@ -914,6 +948,7 @@ export const createAgentStatusSlice: StateCreator { + const generatedTitleUpdates: AgentStatusEntry[] = [] set((s) => { const runtimeMapChanged = !orchestrationMapsEqual( s.runtimeAgentOrchestrationByPaneKey, @@ -936,7 +971,19 @@ export const createAgentStatusSlice: StateCreator { @@ -1071,6 +1127,7 @@ export const createAgentStatusSlice: StateCreator { const existing = s.agentStatusByPaneKey[paneKey] // Why: snapshots and live pushes share receivedAt from the same main-side @@ -1245,6 +1302,7 @@ export const createAgentStatusSlice: StateCreator freshness.schedule()) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 1440b921aca..211dded1173 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -582,7 +582,11 @@ export type TerminalSlice = { setActiveTab: (tabId: string) => void setActiveTabForWorktree: (worktreeId: string, tabId: string) => void updateTabTitle: (tabId: string, title: string) => void - setGeneratedTabTitleFromAgentPrompt: (paneKey: string, prompt: string) => void + setGeneratedTabTitleFromAgentPrompt: ( + paneKey: string, + prompt: string, + options?: { replaceExistingGeneratedTitle?: boolean } + ) => void clearTabLaunchAgent: (tabId: string) => void setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void clearRuntimePaneTitle: (tabId: string, paneId: number) => void @@ -1547,35 +1551,59 @@ export const createTerminalSlice: StateCreator }) }, - setGeneratedTabTitleFromAgentPrompt: (paneKey, prompt) => { + setGeneratedTabTitleFromAgentPrompt: (paneKey, prompt, options) => { + // Why: setAgentStatus is high-frequency; skip derive/set unless the feature + // is on and this tab still needs a (re)generated title. + if (get().settings?.tabAutoGenerateTitle !== true) { + return + } const tabId = getTabIdFromPaneKey(paneKey) if (!tabId || prompt.length === 0) { return } + const ownerWorktreeId = getTerminalTabOwnerWorktreeId(get().tabsByWorktree, tabId) + if (!ownerWorktreeId) { + return + } + const tabs = get().tabsByWorktree[ownerWorktreeId] ?? [] + const currentTab = tabs.find((tab) => tab.id === tabId) + if (!currentTab || currentTab.customTitle?.trim() || currentTab.quickCommandLabel?.trim()) { + return + } + const existingGeneratedTitle = currentTab.generatedTitle?.trim() + if (existingGeneratedTitle && options?.replaceExistingGeneratedTitle !== true) { + return + } + const generatedTitle = deriveGeneratedTabTitle(prompt) + if (!generatedTitle || existingGeneratedTitle === generatedTitle) { + return + } set((s) => { - if (s.settings?.tabAutoGenerateTitle !== true) { + const ownerTabsForWrite = s.tabsByWorktree[ownerWorktreeId] + if (!ownerTabsForWrite) { return s } - const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) - if (!ownerWorktreeId) { - return s - } - const tabs = s.tabsByWorktree[ownerWorktreeId] ?? [] - const tabIndex = tabs.findIndex((tab) => tab.id === tabId) - const currentTab = tabs[tabIndex] + const tabIndex = ownerTabsForWrite.findIndex((tab) => tab.id === tabId) + const tabForWrite = ownerTabsForWrite[tabIndex] + // Why: re-check inside set so concurrent renames / setting flips win. if ( - !currentTab || - currentTab.customTitle?.trim() || - currentTab.quickCommandLabel?.trim() || - currentTab.generatedTitle?.trim() + !tabForWrite || + s.settings?.tabAutoGenerateTitle !== true || + tabForWrite.customTitle?.trim() || + tabForWrite.quickCommandLabel?.trim() ) { return s } - const generatedTitle = deriveGeneratedTabTitle(prompt) - if (!generatedTitle) { + const latestGeneratedTitle = tabForWrite.generatedTitle?.trim() + if ( + latestGeneratedTitle && + (latestGeneratedTitle === generatedTitle || options?.replaceExistingGeneratedTitle !== true) + ) { return s } - const ownerTabs = tabs.map((tab) => (tab.id === tabId ? { ...tab, generatedTitle } : tab)) + const ownerTabs = ownerTabsForWrite.map((tab) => + tab.id === tabId ? { ...tab, generatedTitle } : tab + ) const currentUnifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? [] const unifiedTabsWithGeneratedLabel = updateUnifiedTerminalGeneratedLabel( currentUnifiedTabs, From d43dcc7f2ad30742e0767860af3e756dc6f3d7d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:46:25 +0000 Subject: [PATCH 16/17] release: v1.4.132-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index de31411d156..65f4c0c7b34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.131-rc.2", + "version": "1.4.132-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 5d5e2c4b9551497e795add754c826e93307e63e4 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:00:01 -0700 Subject: [PATCH 17/17] Add updater diagnostics and refine daemon teardown policy on quit (#7544) * Add updater diagnostics and refine daemon teardown policy on quit Introduce detailed logging, crash breadcrumbs, and telemetry tracing events for the update quit-and-install lifecycle. This improves visibility into macOS Squirrel.Mac state transitions, install guard timeouts, and pre-quit cleanup failures. Also, formalize and unit test the daemon teardown policy on quit to explicitly keep the daemon alive (via disconnect) during updater and normal quits so that daemon-backed PTYs are preserved for warm reattach. * Remove updater lifecycle logging Remove calls to `recordUpdaterLifecycle` across the auto-updater modules. This cleans up unused or redundant telemetry events during macOS update download, ready, and pre-quit installation phases. * Remove redundant quit daemon teardown policy helper Inline the daemon teardown check directly into the `will-quit` event handler in the main process. The deleted `shouldShutdownDaemonForQuit` helper simply returned `isDevParentShutdownRequested`, so using that check directly simplifies the codebase and allows removing the redundant policy and test files. * Clarify PTY cleanup targets only local in-process PTYs Update doc comments, test descriptions, and telemetry events to make it explicit that only local, in-process PTYs are killed on quit. Daemon-backed PTYs are preserved via daemon disconnect. * Recover quit-for-update state on autoUpdater error events - Reset update flags when the installer fails asynchronously via the autoUpdater 'error' event (such as "no staged update" errors). - Prevents the app from getting stuck in a half-transitioned state where PTYs are killed and windows refuse to reopen. - Migrate legacy updater warning logs and crash breadcrumbs to use the structured recordUpdaterLifecycle diagnostic system. * Simplify duplicate isMacInstallerReady() call in update-downloaded handl Cache the result in a local variable so the deferred-status check reuses the value from the log call instead of recomputing it. * fix: tighten quit-and-install failure recovery Only recover stuck quit-for-update flags after native quitAndInstall is invoked and before install is committed. Defer PTY kill and close-listener removal until after a successful native handoff so sync install failures keep the session intact, and ignore post-commit updater errors for UI/mac state. * fix: typecheck login-shell args check in shell-ready test Avoid args.includes('-l') on a const tuple union that only admits '-i' on one branch; compare args[0] instead so merge-base typecheck passes. --- src/main/index.ts | 14 ++ src/main/ipc/pty.ts | 2 +- src/main/providers/local-pty-provider.ts | 2 +- .../providers/local-pty-shell-ready.test.ts | 4 +- src/main/updater-events.ts | 34 ++++- src/main/updater-lifecycle-diagnostics.ts | 12 ++ src/main/updater-mac-install.ts | 21 ++- src/main/updater.mac-install.test.ts | 4 +- src/main/updater.test.ts | 141 ++++++++++++++++- src/main/updater.ts | 143 ++++++++++++++++-- 10 files changed, 352 insertions(+), 25 deletions(-) create mode 100644 src/main/updater-lifecycle-diagnostics.ts diff --git a/src/main/index.ts b/src/main/index.ts index 0eb2d9d9a4f..0351ecf1dde 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -51,6 +51,7 @@ import { rebuildAppMenu } from './menu/register-app-menu' import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater' +import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { configureElectronNetworkCompatibility, configureDevUserDataPath, @@ -2210,6 +2211,11 @@ app.whenReady().then(async () => { }) app.on('before-quit', () => { + if (isQuittingForUpdate()) { + recordUpdaterLifecycle('before_quit_allowed', undefined, { + message: 'before-quit allowed for update install' + }) + } isQuitting = true unsubscribeSystemResumeBroadcast?.() unsubscribeSystemResumeBroadcast = null @@ -2231,6 +2237,14 @@ app.on('before-quit', () => { // async work and let Electron exit. let daemonDisconnectDone = false app.on('will-quit', (e) => { + const updateQuitInProgress = isQuittingForUpdate() + if (updateQuitInProgress) { + recordUpdaterLifecycle( + 'will_quit_cleanup_started', + { daemonTeardown: 'disconnect' }, + { message: 'will-quit cleanup for update install; daemonTeardown=disconnect' } + ) + } // Why: before-quit can still be aborted by renderer beforeunload; wait until // the committed quit path before removing the Windows notification icon. destroySystemTray() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 6848e411c06..c6c0c6c5997 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3928,7 +3928,7 @@ export function registerHeadlessPtyRuntime( } /** - * Kill all PTY processes. Call on app quit. + * Kill in-process local PTYs. Daemon-backed PTYs are preserved by daemon disconnect. */ export function killAllPty(): void { if (localProvider instanceof LocalPtyProvider) { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 5b0e5ea84da..661b877abbf 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -1044,7 +1044,7 @@ export class LocalPtyProvider implements IPtyProvider { return ptyProcesses.get(id) } - /** Kill all PTYs. Call on app quit. */ + /** Kill all in-process local PTYs. Call on app quit. */ killAll(): void { for (const [id, proc] of ptyProcesses) { safeKillAndClean(id, proc) diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index e72fc25cbf8..677c2c65946 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -883,7 +883,9 @@ path=(/custom/bin $path) expect(result.status, `zsh ${args.join(' ')} failed: ${result.stderr}`).toBe(0) expect(result.stdout).toContain('USER_ZSHRC_LOADED=yes') expect(result.stdout).toContain(`FINAL_ZDOTDIR=${testHome}`) - expect(result.stdout).toContain(args.includes('-l') ? 'IS_LOGIN=yes' : 'IS_LOGIN=no') + // Why: `as const` makes .includes('-l') reject the union of tuple + // element types; check the login flag by position instead. + expect(result.stdout).toContain(args[0] === '-l' ? 'IS_LOGIN=yes' : 'IS_LOGIN=no') } } finally { rmSync(movedUserData, { recursive: true, force: true }) diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index 3b112ba31a6..27936f988ce 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -11,6 +11,7 @@ import { import { compareVersions } from './updater-fallback' import { fetchChangelog } from './updater-changelog' import type { ElectronAutoUpdater } from './electron-updater-loader' +import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 @@ -27,6 +28,8 @@ type UpdaterHandlerContext = { getKnownReleaseUrl: () => string | undefined getPendingInstallVersion: () => string getUserInitiatedCheck: () => boolean + handleQuitAndInstallFailure: () => boolean + isQuitAndInstallHandoffActive: () => boolean hasNewerDownloadedVersion: () => boolean shouldHandleUpdaterErrorEvent: () => boolean clearUpdateAvailableEventPending: (attemptId: number | null) => void @@ -64,6 +67,8 @@ export function registerAutoUpdaterHandlers({ getKnownReleaseUrl, getPendingInstallVersion, getUserInitiatedCheck, + handleQuitAndInstallFailure, + isQuitAndInstallHandoffActive, hasNewerDownloadedVersion, shouldHandleUpdaterErrorEvent, clearUpdateAvailableEventPending, @@ -89,7 +94,8 @@ export function registerAutoUpdaterHandlers({ // Track Squirrel readiness so we don't show "ready to install" prematurely. if (process.platform === 'darwin') { nativeUpdater.on('update-downloaded', () => { - handleMacInstallerReady(hasNewerDownloadedVersion(), performQuitAndInstall, () => { + const hasNewerVersion = hasNewerDownloadedVersion() + handleMacInstallerReady(hasNewerVersion, performQuitAndInstall, () => { // If we were holding the 'downloaded' status, send it now — but only // when the staged version is actually newer than what's running. sendStatus({ @@ -102,7 +108,11 @@ export function registerAutoUpdaterHandlers({ } app.on('before-quit', (event) => { - if (consumeMacInstallGuardBypass() || isMacQuitAndInstallInFlight()) { + if (consumeMacInstallGuardBypass()) { + recordUpdaterLifecycle('macos_before_quit_guard_bypassed') + return + } + if (isMacQuitAndInstallInFlight()) { return } @@ -119,6 +129,9 @@ export function registerAutoUpdaterHandlers({ sendStatus ) ) { + recordUpdaterLifecycle('macos_before_quit_deferred', { + version: getPendingInstallVersion() + }) event.preventDefault() } }) @@ -255,12 +268,15 @@ export function registerAutoUpdaterHandlers({ sendStatus({ state: 'not-available' }) return } + const macInstallerReady = process.platform === 'darwin' ? isMacInstallerReady() : true + recordUpdaterLifecycle('update_downloaded', { version: info.version, macInstallerReady }) // On macOS, defer the 'downloaded' status until Squirrel.Mac has finished // processing the update via the localhost proxy. On other platforms, // the update is ready immediately after electron-updater downloads it. - if (process.platform === 'darwin' && !isMacInstallerReady()) { + if (process.platform === 'darwin' && !macInstallerReady) { // Squirrel is still processing. Keep the UI at 100% downloaded so the // user sees the handoff instead of a misleading "ready to install". + recordUpdaterLifecycle('macos_waiting_for_squirrel', { version: info.version }) sendStatus({ state: 'downloading', percent: 100, version: info.version }) return } @@ -269,6 +285,18 @@ export function registerAutoUpdaterHandlers({ autoUpdater.on('error', (err) => { const message = err?.message ?? 'Unknown error' + // Why: quitAndInstall reports the common "no staged update" failure through + // this event (often sync on Win/Linux, async on macOS/spawn). Recover + // quit-for-update flags before any suppression guard can early-return, but + // only after native invoke and only when install is not yet committed. + if (handleQuitAndInstallFailure()) { + return + } + // Why: handoff still owns the process (cleanup, native in-flight, or + // post-commit). Do not treat as check/download error or reset mac install. + if (isQuitAndInstallHandoffActive()) { + return + } // Why: primary/fallback promise handlers may already own this failure; do // not let their delayed paired error event consume fallback context. if (shouldSuppressMissingManifestPrereleaseFallbackEvent(message, err)) { diff --git a/src/main/updater-lifecycle-diagnostics.ts b/src/main/updater-lifecycle-diagnostics.ts new file mode 100644 index 00000000000..d5f0a10bfa8 --- /dev/null +++ b/src/main/updater-lifecycle-diagnostics.ts @@ -0,0 +1,12 @@ +import type { CrashReportBreadcrumbData } from '../shared/crash-reporting' +import { recordCrashBreadcrumb } from './crash-reporting/crash-breadcrumb-store' + +export function recordUpdaterLifecycle( + event: string, + data?: CrashReportBreadcrumbData, + options?: { level?: 'info' | 'warn' | 'error'; message?: string } +): void { + recordCrashBreadcrumb(`updater_${event}`, data) + const suffix = data && Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : '' + console[options?.level ?? 'info'](`[updater] ${options?.message ?? event}${suffix}`) +} diff --git a/src/main/updater-mac-install.ts b/src/main/updater-mac-install.ts index 086e7d5fc6c..8012f79d8fc 100644 --- a/src/main/updater-mac-install.ts +++ b/src/main/updater-mac-install.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import type { UpdateStatus } from '../shared/types' +import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' const MAC_INSTALL_READY_TIMEOUT_MS = 15000 @@ -96,8 +97,13 @@ export function deferMacQuitUntilInstallerReady( return } - console.warn( - `[updater] macOS installer was not ready after ${MAC_INSTALL_READY_TIMEOUT_MS}ms; allowing quit without install` + recordUpdaterLifecycle( + 'macos_install_guard_timeout', + { timeoutMs: MAC_INSTALL_READY_TIMEOUT_MS }, + { + level: 'warn', + message: `macOS installer was not ready after ${MAC_INSTALL_READY_TIMEOUT_MS}ms; allowing quit without install` + } ) installRequestedAfterSquirrelReady = false // This is a safety valve. The updater path should wait for ShipIt so the @@ -117,14 +123,19 @@ export function handleMacInstallerReady( ): void { squirrelReady = true clearPendingInstallTimeout() + recordUpdaterLifecycle('macos_installer_ready', { + deferredInstallRequested: installRequestedAfterSquirrelReady, + hasNewerDownloadedVersion + }) if (installRequestedAfterSquirrelReady && hasNewerDownloadedVersion) { void Promise.resolve() .then(() => onReadyToInstall()) .catch((error) => { - console.warn( - '[updater] Deferred macOS install handoff failed:', - error instanceof Error ? error.name : typeof error + recordUpdaterLifecycle( + 'macos_deferred_install_handoff_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { level: 'warn', message: 'Deferred macOS install handoff failed' } ) }) return diff --git a/src/main/updater.mac-install.test.ts b/src/main/updater.mac-install.test.ts index 2026b354edc..f48754afd4f 100644 --- a/src/main/updater.mac-install.test.ts +++ b/src/main/updater.mac-install.test.ts @@ -258,9 +258,9 @@ describe('updater mac install handoff', () => { expect(reportDownloaded).not.toHaveBeenCalled() await vi.waitFor(() => { + // Why: recordUpdaterLifecycle packs metadata into one console line. expect(warn).toHaveBeenCalledWith( - '[updater] Deferred macOS install handoff failed:', - 'Error' + '[updater] Deferred macOS install handoff failed {"errorType":"Error"}' ) }) expect(JSON.stringify(warn.mock.calls)).not.toContain('handoff-secret') diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index defad8588e5..f9905ae1824 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -1177,7 +1177,7 @@ describe('updater', () => { expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(false, true) }) - it('runs pre-quit cleanup before killing PTYs during update install', async () => { + it('runs pre-quit cleanup before local PTY cleanup during update install', async () => { vi.useFakeTimers() const onBeforeQuit = vi.fn() @@ -1240,6 +1240,145 @@ describe('updater', () => { expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) }) + it('recovers quit-for-update state on sync quitAndInstall error event without killing PTYs', async () => { + vi.useFakeTimers() + + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + // Why: BaseUpdater dispatches 'error' synchronously inside install() for + // the common "no staged update filepath" path. + autoUpdaterMock.emit( + 'error', + new Error("No update filepath provided, can't quit and install") + ) + }) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + const { setupAutoUpdater, quitAndInstall, isQuittingForUpdate } = await import('./updater') + + setupAutoUpdater(mainWindow as never) + quitAndInstall() + + await vi.advanceTimersByTimeAsync(100) + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(isQuittingForUpdate()).toBe(false) + // Why: destructive prep runs only after quitAndInstall returns still in + // progress; sync recovery clears flags first so PTYs stay alive. + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(sendMock).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'error', + message: 'Could not restart to install the update. Quit and reopen Orca, then try again.' + }) + ) + }) + + it('does not recover quit-for-update state from late errors after install commit', async () => { + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + }) + return Promise.resolve(undefined) + }) + + const { setupAutoUpdater, checkForUpdatesFromMenu, quitAndInstall, isQuittingForUpdate } = + await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + checkForUpdatesFromMenu() + + // Why: put status in downloaded so a naive error handler would otherwise + // treat a late post-commit error as a download/install UI failure. + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'available', + version: '1.0.61', + changelog: null + }) + }) + + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + + // Why: on macOS install is only "committed" once Squirrel is ready; mark + // it ready so this test covers the post-commit path on all platforms and + // the UI can leave the "waiting for Squirrel" downloading state. + if (process.platform === 'darwin') { + const nativeDownloadedHandler = nativeUpdaterMock.on.mock.calls.find( + ([eventName]) => eventName === 'update-downloaded' + )?.[1] as (() => void) | undefined + expect(nativeDownloadedHandler).toBeTypeOf('function') + nativeDownloadedHandler?.() + } + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ state: 'downloaded', version: '1.0.61' }) + ) + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + expect(killAllPtyMock).toHaveBeenCalledTimes(1) + expect(isQuittingForUpdate()).toBe(true) + + sendMock.mockClear() + autoUpdaterMock.emit('error', new Error('late post-commit install error')) + + expect(isQuittingForUpdate()).toBe(true) + // Why: handoff still owns the process after commit — no recovery message + // and no general check/download error status either. + expect(sendMock).not.toHaveBeenCalled() + }) + + it('does not treat pre-native autoUpdater errors as quitAndInstall recovery', async () => { + vi.useFakeTimers() + + let finishCleanup!: () => void + const onBeforeQuit = vi.fn( + () => + new Promise((resolve) => { + finishCleanup = resolve + }) + ) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + const { setupAutoUpdater, quitAndInstall, isQuittingForUpdate } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { + onBeforeQuit, + getLastUpdateCheckAt: () => Date.now() + }) + quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(true) + + sendMock.mockClear() + // Why: an unrelated error during pre-quit cleanup must not clear + // quittingForUpdate or emit the install-recovery status (native not invoked). + autoUpdaterMock.emit('error', new Error('pre-native concurrent error')) + + expect(isQuittingForUpdate()).toBe(true) + expect(sendMock).not.toHaveBeenCalled() + + finishCleanup() + await vi.advanceTimersByTimeAsync(0) + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(isQuittingForUpdate()).toBe(true) + }) + it('runs a startup check immediately when the last background check is stale', async () => { const mainWindow = { webContents: { send: vi.fn() } } const setLastUpdateCheckAt = vi.fn() diff --git a/src/main/updater.ts b/src/main/updater.ts index 42924bdf08c..5bb2340fbf9 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -9,9 +9,12 @@ import { writeMainThreadDiagnosticMarker } from './diagnostics/main-thread-churn import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady, - markMacQuitAndInstallInFlight + isMacInstallerReady, + markMacQuitAndInstallInFlight, + resetMacInstallState } from './updater-mac-install' import { registerAutoUpdaterHandlers } from './updater-events' +import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { compareVersions, isBenignCheckFailure, @@ -64,6 +67,14 @@ let autoUpdateCheckTimer: ReturnType | null = null let nudgeCheckTimer: ReturnType | null = null let pendingQuitAndInstallTimer: ReturnType | null = null let quitAndInstallInProgress = false +// Why: once quitAndInstall has committed (Win/Linux install, or macOS with +// Squirrel ready), late autoUpdater 'error' events must not clear +// quittingForUpdate — that would re-enable dock activate mid-installer. +let updateInstallCommitted = false +// Why: quit-and-install recovery must only run after the native +// quitAndInstall call. Pre-native cleanup-time autoUpdater errors must not +// clear quittingForUpdate or look like install recovery. +let quitAndInstallNativeInvoked = false let persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null let _getLastUpdateCheckAt: (() => number | null) | null = null let backgroundCheckLaunchPending = false @@ -547,6 +558,7 @@ function clearPrereleaseFallbackContextIfSettled(): void { async function performQuitAndInstall(): Promise { if (quitAndInstallInProgress) { + recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' }) return } quitAndInstallInProgress = true @@ -566,14 +578,112 @@ async function performQuitAndInstall(): Promise { // either can't replace it or the user ends up on the old version. quittingForUpdate = true - await runBeforeUpdateQuitCleanup() - killAllPty() + const pendingVersion = getPendingInstallVersion() + try { + await withUpdaterSpan({ stage: 'install' }, async (span) => { + span.setAttribute('updater.version', pendingVersion || 'unknown') + span.setAttribute('updater.platform', process.platform) + span.setAttribute( + 'updater.macosInstallerReady', + process.platform === 'darwin' ? isMacInstallerReady() : true + ) + recordUpdaterLifecycle('quit_and_install_started', { + version: pendingVersion || null, + macInstallerReady: process.platform === 'darwin' ? isMacInstallerReady() : true + }) + span.addEvent('pre_quit_cleanup_start') + await runBeforeUpdateQuitCleanup() + span.addEvent('pre_quit_cleanup_done') - for (const win of BrowserWindow.getAllWindows()) { - win.removeAllListeners('close') + recordUpdaterLifecycle('quit_and_install_invoking_native', { + version: pendingVersion || null + }) + // Why: defensive — state should stay in-progress until native invoke, but + // never call quitAndInstall if recovery/reset already cleared the handoff. + if (!quitAndInstallInProgress) { + return + } + // Why: mark before the call so a sync 'error' during quitAndInstall can + // recover; pre-native errors must not look like install failure. + quitAndInstallNativeInvoked = true + // Why: invoke quitAndInstall before killAllPty/remove close listeners so a + // sync 'error' (common "no filepath" path) recovers while windows and + // local PTYs are still intact. + getAutoUpdater().quitAndInstall(false, true) + span.addEvent('native_quit_and_install_invoked') + + // Why: handleQuitAndInstallFailure may clear quitAndInstallInProgress + // synchronously during quitAndInstall (Win/Linux dispatchError). Skip + // destructive prep if recovery already ran. + if (!quitAndInstallInProgress) { + return + } + + killAllPty() + span.addEvent('local_pty_kill_all') + + for (const win of BrowserWindow.getAllWindows()) { + win.removeAllListeners('close') + } + span.addEvent('window_close_listeners_removed', { + windowCount: BrowserWindow.getAllWindows().length + }) + + // Why: committed installs must keep quittingForUpdate true so dock + // activate cannot reopen the old process mid-ShipIt/installer. macOS + // without Squirrel ready stays uncommitted so late native errors can + // still recover flags (PTYs may already be dead — residual OK). + if (process.platform !== 'darwin' || isMacInstallerReady()) { + updateInstallCommitted = true + } + }) + } catch (error) { + resetQuitForUpdateState() + recordUpdaterLifecycle( + 'quit_and_install_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Could not start update install' + } + ) + sendErrorStatus( + 'Could not restart to install the update. Quit and reopen Orca, then try again.' + ) } +} - getAutoUpdater().quitAndInstall(false, true) +function resetQuitForUpdateState(): void { + quitAndInstallInProgress = false + quittingForUpdate = false + updateInstallCommitted = false + quitAndInstallNativeInvoked = false + resetMacInstallState() +} + +// Why: electron-updater often reports quitAndInstall failures via the 'error' +// event. On Win/Linux this is frequently synchronous (dispatchError inside +// install()); on macOS/spawn it can be async. Recover only after native invoke +// and only when install has not been committed — after commit, clearing +// quittingForUpdate would allow dock activate to reopen the old process +// mid-installer. +function handleQuitAndInstallFailure(): boolean { + if (!quitAndInstallInProgress || !quitAndInstallNativeInvoked || updateInstallCommitted) { + return false + } + resetQuitForUpdateState() + recordUpdaterLifecycle('quit_and_install_failed_via_event', undefined, { + level: 'warn', + message: 'Update install could not start; recovered app state' + }) + sendErrorStatus('Could not restart to install the update. Quit and reopen Orca, then try again.') + return true +} + +// Why: while quit-and-install owns the process (pre-native cleanup through +// post-commit handoff), general check/download error UI must not run. +function isQuitAndInstallHandoffActive(): boolean { + return quitAndInstallInProgress } async function runBeforeUpdateQuitCleanup(): Promise { @@ -585,9 +695,13 @@ async function runBeforeUpdateQuitCleanup(): Promise { const cleanup = Promise.resolve() .then(() => onBeforeQuitCleanup?.()) .catch((error) => { - console.warn( - '[updater] Pre-quit cleanup failed; continuing update install:', - error instanceof Error ? error.name : typeof error + recordUpdaterLifecycle( + 'pre_quit_cleanup_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Pre-quit cleanup failed; continuing update install' + } ) }) const timeoutResult = new Promise<'timeout'>((resolve) => { @@ -596,8 +710,13 @@ async function runBeforeUpdateQuitCleanup(): Promise { const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) if (result === 'timeout') { - console.warn( - `[updater] Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` + recordUpdaterLifecycle( + 'pre_quit_cleanup_timeout', + { timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS }, + { + level: 'warn', + message: `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` + } ) return } @@ -1304,6 +1423,8 @@ export function setupAutoUpdater( getKnownReleaseUrl, getPendingInstallVersion, getUserInitiatedCheck: () => userInitiatedCheck, + handleQuitAndInstallFailure, + isQuitAndInstallHandoffActive, hasNewerDownloadedVersion, shouldHandleUpdaterErrorEvent, performQuitAndInstall,