From 4ee41fede2df4b61977c22cb0349ffdd6ec808da Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:59:30 -0700 Subject: [PATCH 01/12] fix(automations): reveal full prompt from detail view (#16067) --- .../automations/AutomationDetail.tsx | 20 +- .../AutomationPromptDisclosure.test.tsx | 234 ++++++++++++++++++ .../AutomationPromptDisclosure.tsx | 93 +++++++ src/renderer/src/i18n/locales/en.json | 4 + src/renderer/src/i18n/locales/es.json | 4 + src/renderer/src/i18n/locales/ja.json | 4 + src/renderer/src/i18n/locales/ko.json | 4 + src/renderer/src/i18n/locales/zh.json | 4 + .../e2e/automation-prompt-disclosure.spec.ts | 158 ++++++++++++ 9 files changed, 510 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationPromptDisclosure.tsx create mode 100644 tests/e2e/automation-prompt-disclosure.spec.ts diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx index ef491c816c6..c1ed6165295 100644 --- a/src/renderer/src/components/automations/AutomationDetail.tsx +++ b/src/renderer/src/components/automations/AutomationDetail.tsx @@ -16,6 +16,7 @@ import { import type { AutomationTargetAvailability } from './automation-target-availability' import { getAutomationSourceDisplay } from './automation-source-display' import { translate } from '@/i18n/i18n' +import { AutomationPromptDisclosure } from './AutomationPromptDisclosure' type AutomationDetailProps = { automation: Automation | null @@ -301,21 +302,10 @@ export function AutomationDetail({ /> -
-
- {translate('auto.components.automations.AutomationDetail.007c8ad874', 'Prompt')} -
-
-
-
- {translate('auto.components.automations.AutomationDetail.007c8ad874', 'Prompt')} -
-

- {automation.prompt} -

-
-
-
+ ) } diff --git a/src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx b/src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx new file mode 100644 index 00000000000..6ac693aea82 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx @@ -0,0 +1,234 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import type React from 'react' +import { act, cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { i18n } from '@/i18n/i18n' +import { AutomationDetail } from './AutomationDetail' +import { AutomationPromptDisclosure } from './AutomationPromptDisclosure' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +let promptNaturalHeight = 0 +let resizeCallback: ResizeObserverCallback | null = null + +class PromptResizeObserver implements ResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback + } + + disconnect(): void {} + observe(): void {} + unobserve(): void {} +} + +function makeAutomation( + updatedAt: number, + prompt = `Synthetic opening.\n${'Synthetic detail. '.repeat(80)}` +): Automation { + return { + id: 'synthetic-automation', + name: 'Synthetic automation', + prompt, + precheck: null, + agentId: 'codex', + projectId: 'synthetic-project', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: 'synthetic-workspace', + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 1, + enabled: false, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt + } +} + +const detailCallbacks = { + onRunNow: vi.fn(), + onEdit: vi.fn(), + onToggle: vi.fn(), + onDelete: vi.fn() +} + +describe('AutomationPromptDisclosure', () => { + beforeEach(async () => { + await i18n.changeLanguage('en') + vi.stubGlobal('ResizeObserver', PromptResizeObserver) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation( + function (this: HTMLElement) { + if (this.tagName !== 'P') { + return 0 + } + return this.classList.contains('line-clamp-4') + ? Math.min(promptNaturalHeight, 80) + : promptNaturalHeight + } + ) + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation( + function (this: HTMLElement) { + return this.tagName === 'P' ? promptNaturalHeight : 0 + } + ) + }) + + afterEach(async () => { + cleanup() + resizeCallback = null + vi.restoreAllMocks() + vi.unstubAllGlobals() + await i18n.changeLanguage('en') + }) + + it('leaves a short prompt fully readable without a disclosure control', () => { + promptNaturalHeight = 40 + render() + + expect(screen.getByText('Synthetic short prompt.')).toBeVisible() + expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument() + }) + + it('labels the prompt correctly in Spanish', async () => { + promptNaturalHeight = 40 + await i18n.changeLanguage('es') + render() + + expect(screen.getByText('Prompt')).toBeVisible() + expect(screen.queryByText('Inmediato')).not.toBeInTheDocument() + }) + + it('reveals the complete long prompt from the keyboard and keeps it selectable', async () => { + promptNaturalHeight = 240 + const prompt = `Synthetic opening.\n${'Synthetic detail. '.repeat(80)}\nSYNTHETIC-END-MARKER` + const user = userEvent.setup() + render() + + const content = screen.getByText(/SYNTHETIC-END-MARKER/) + const toggle = screen.getByRole('button', { name: 'Show more' }) + expect(screen.getAllByText('Prompt')).toHaveLength(1) + expect(content).toHaveClass('line-clamp-4', 'select-text', '[overflow-wrap:anywhere]') + expect(toggle).toHaveAttribute('aria-expanded', 'false') + expect(toggle).toHaveAttribute('aria-controls', content.id) + expect(toggle).toHaveAttribute('data-variant', 'ghost') + expect(toggle).toHaveAttribute('data-size', 'xs') + expect(toggle).not.toHaveClass('h-auto', 'p-0') + + await user.tab() + expect(toggle).toHaveFocus() + await user.keyboard('{Enter}') + + expect(screen.getByRole('button', { name: 'Show less' })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + expect(content).not.toHaveClass('line-clamp-4') + expect(content).toHaveTextContent('SYNTHETIC-END-MARKER') + + await user.click(screen.getByRole('button', { name: 'Show less' })) + expect(screen.getByRole('button', { name: 'Show more' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(content).toHaveClass('line-clamp-4') + + await user.click(screen.getByRole('button', { name: 'Show more' })) + act(() => resizeCallback?.([], {} as ResizeObserver)) + expect(screen.getByRole('button', { name: 'Show less' })).toHaveFocus() + + await user.click(screen.getByRole('button', { name: 'Show less' })) + expect(screen.getByRole('button', { name: 'Show more' })).toHaveFocus() + expect(content).toHaveClass('line-clamp-4') + }) + + it('offers disclosure after a narrow resize makes the prompt overflow', () => { + promptNaturalHeight = 60 + render() + expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument() + + promptNaturalHeight = 180 + act(() => resizeCallback?.([], {} as ResizeObserver)) + + expect(screen.getByRole('button', { name: 'Show more' })).toBeVisible() + }) + + it('moves focus to the fully visible prompt when resizing removes the disclosure', () => { + promptNaturalHeight = 180 + render() + const content = screen.getByText('Synthetic prompt that fits after widening.') + screen.getByRole('button', { name: 'Show more' }).focus() + + promptNaturalHeight = 60 + act(() => resizeCallback?.([], {} as ResizeObserver)) + + expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument() + expect(content).toHaveFocus() + }) + + it('preserves expansion and focus across unrelated automation updates', async () => { + promptNaturalHeight = 240 + const user = userEvent.setup() + const { rerender } = render( + + ) + + await user.click(screen.getByRole('button', { name: 'Show more' })) + const showLess = screen.getByRole('button', { name: 'Show less' }) + showLess.focus() + + rerender( + + ) + + expect(screen.getByRole('button', { name: 'Show less' })).toHaveFocus() + + promptNaturalHeight = 40 + rerender( + + ) + + expect(screen.getByText('Synthetic replacement prompt.')).toBeVisible() + expect(screen.queryByRole('button', { name: 'Show less' })).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationPromptDisclosure.tsx b/src/renderer/src/components/automations/AutomationPromptDisclosure.tsx new file mode 100644 index 00000000000..b5eefc7dd74 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationPromptDisclosure.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useEffect, useId, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' + +export function AutomationPromptDisclosure({ prompt }: { prompt: string }): React.JSX.Element { + const contentId = useId() + const expandedRef = useRef(false) + const toggleRef = useRef(null) + const [promptElement, setPromptElement] = useState(null) + const [expanded, setExpanded] = useState(false) + const [overflows, setOverflows] = useState(false) + + const measureOverflow = useCallback((element: HTMLParagraphElement) => { + const nextOverflows = element.scrollHeight > element.clientHeight + 1 + if (!nextOverflows && document.activeElement === toggleRef.current) { + element.focus({ preventScroll: true }) + } + setOverflows((current) => (current === nextOverflows ? current : nextOverflows)) + }, []) + + useEffect(() => { + if (!promptElement || expanded) { + return + } + + const updateOverflow = () => { + if (!expandedRef.current) { + measureOverflow(promptElement) + } + } + updateOverflow() + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateOverflow) + return () => window.removeEventListener('resize', updateOverflow) + } + + const observer = new ResizeObserver(updateOverflow) + observer.observe(promptElement) + return () => observer.disconnect() + }, [expanded, measureOverflow, promptElement]) + + const toggleExpanded = (): void => { + const nextExpanded = !expanded + expandedRef.current = nextExpanded + setExpanded(nextExpanded) + } + + return ( +
+
+
+ {translate('auto.components.automations.AutomationEditorDialog.058c23cb3f', 'Prompt')} +
+ {overflows || expanded ? ( + + ) : null} +
+
+

+ {prompt} +

+
+
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 291cc9395de..c117b08e172 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15008,6 +15008,10 @@ "0e1de0358b": "Month", "77e96bded6": "Weekday" }, + "AutomationPromptDisclosure": { + "showLess": "Show less", + "showMore": "Show more" + }, "AutomationDetail": { "007c8ad874": "Prompt", "a1d52c2189": "Usage coverage", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 27136599f39..9f1ef9ca5cc 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13612,6 +13612,10 @@ "0e1de0358b": "Mes", "77e96bded6": "Día de la semana" }, + "AutomationPromptDisclosure": { + "showLess": "Mostrar menos", + "showMore": "Mostrar más" + }, "AutomationDetail": { "007c8ad874": "Inmediato", "a1d52c2189": "Cobertura de uso", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1a2f3e9f95a..508c9d9bec3 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13612,6 +13612,10 @@ "0e1de0358b": "月", "77e96bded6": "曜日" }, + "AutomationPromptDisclosure": { + "showLess": "折りたたむ", + "showMore": "さらに表示" + }, "AutomationDetail": { "007c8ad874": "プロンプト", "a1d52c2189": "使用範囲", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 40e5469d0ae..2384a7996fc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13641,6 +13641,10 @@ "0e1de0358b": "월", "77e96bded6": "요일" }, + "AutomationPromptDisclosure": { + "showLess": "접기", + "showMore": "더 보기" + }, "AutomationDetail": { "007c8ad874": "프롬프트", "a1d52c2189": "사용 범위", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6ad65d655e8..fdd53f16ed9 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13632,6 +13632,10 @@ "0e1de0358b": "月", "77e96bded6": "星期" }, + "AutomationPromptDisclosure": { + "showLess": "收起", + "showMore": "显示更多" + }, "AutomationDetail": { "007c8ad874": "提示词", "a1d52c2189": "使用范围", diff --git a/tests/e2e/automation-prompt-disclosure.spec.ts b/tests/e2e/automation-prompt-disclosure.spec.ts new file mode 100644 index 00000000000..4137f8495c4 --- /dev/null +++ b/tests/e2e/automation-prompt-disclosure.spec.ts @@ -0,0 +1,158 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +const SHORT_NAME = 'synthetic-short-prompt-demo' +const RESIZE_NAME = 'synthetic-resize-prompt-demo' +const LONG_NAME = 'visual-proof-long-prompt-demo' +const END_MARKER = 'SYNTHETIC-END-MARKER' +const RESIZE_PROMPT = `Synthetic resize focus validation. ${'placeholder '.repeat(24)}` + +test('automation detail keeps short prompts readable and reveals a very long prompt at narrow width', async ({ + orcaPage +}) => { + await waitForSessionReady(orcaPage) + await orcaPage.setViewportSize({ width: 820, height: 700 }) + + await orcaPage.evaluate( + async ({ shortName, resizeName, resizePrompt, longName, endMarker }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const repo = store.getState().repos[0] + if (!repo) { + throw new Error('Seeded test repo is not available') + } + const base = { + agentId: 'codex' as const, + projectId: repo.id, + workspaceMode: 'new_per_run' as const, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: Date.now(), + enabled: false, + missedRunGraceMinutes: 720 + } + await window.api.automations.create({ + ...base, + name: shortName, + prompt: 'Synthetic short prompt.' + }) + await window.api.automations.create({ + ...base, + name: longName, + prompt: [ + 'Synthetic prompt preview validation only.', + '', + ...Array.from( + { length: 20 }, + (_, index) => + `Synthetic step ${index + 1}: inspect placeholder input and summarize placeholder output.` + ), + '', + `UNBROKEN_SYNTHETIC_${'X'.repeat(240)}`, + '', + endMarker + ].join('\n') + }) + await window.api.automations.create({ + ...base, + name: resizeName, + prompt: resizePrompt + }) + store.getState().openAutomationsPage() + }, + { + shortName: SHORT_NAME, + resizeName: RESIZE_NAME, + resizePrompt: RESIZE_PROMPT, + longName: LONG_NAME, + endMarker: END_MARKER + } + ) + + await orcaPage.getByRole('button', { name: new RegExp(`^${SHORT_NAME}`) }).click() + await expect(orcaPage.getByText('Synthetic short prompt.')).toBeVisible() + await expect(orcaPage.getByRole('button', { name: 'Show more' })).toHaveCount(0) + + await orcaPage.getByRole('button', { name: 'All automations' }).click() + await orcaPage.getByRole('button', { name: new RegExp(`^${RESIZE_NAME}`) }).click() + const resizePrompt = orcaPage.getByText(RESIZE_PROMPT) + const resizeToggle = orcaPage.getByRole('button', { name: 'Show more' }) + await expect(resizeToggle).toBeVisible() + await resizeToggle.focus() + await orcaPage.setViewportSize({ width: 1400, height: 700 }) + await expect(resizeToggle).toHaveCount(0) + await expect(resizePrompt).toBeFocused() + + await orcaPage.setViewportSize({ width: 820, height: 700 }) + await orcaPage.getByRole('button', { name: 'All automations' }).click() + await orcaPage.getByRole('button', { name: new RegExp(`^${LONG_NAME}`) }).click() + + const prompt = orcaPage.getByText(new RegExp(END_MARKER)) + const showMore = orcaPage.getByRole('button', { name: 'Show more' }) + await expect(showMore).toBeVisible() + expect(await showMore.getAttribute('aria-controls')).toBe(await prompt.getAttribute('id')) + const collapsedMetrics = await prompt.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + lineClamp: getComputedStyle(element).webkitLineClamp + })) + expect(collapsedMetrics.scrollHeight).toBeGreaterThan(collapsedMetrics.clientHeight) + expect(collapsedMetrics.lineClamp).toBe('4') + + await showMore.focus() + await orcaPage.keyboard.press('Enter') + await expect(orcaPage.getByRole('button', { name: 'Show less' })).toBeFocused() + + const expandedMetrics = await prompt.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + scrollWidth: element.scrollWidth, + clientWidth: element.clientWidth, + lineClamp: getComputedStyle(element).webkitLineClamp, + overflowWrap: getComputedStyle(element).overflowWrap + })) + expect(expandedMetrics.clientHeight).toBeGreaterThan(80) + expect(expandedMetrics.scrollHeight).toBeLessThanOrEqual(expandedMetrics.clientHeight + 1) + expect(expandedMetrics.scrollWidth).toBeLessThanOrEqual(expandedMetrics.clientWidth + 1) + expect(expandedMetrics.lineClamp).toBe('none') + expect(expandedMetrics.overflowWrap).toBe('anywhere') + + const markerProof = await prompt.evaluate(async (element, marker) => { + const text = element.firstChild + if (!(text instanceof Text)) { + throw new Error('Prompt text node is unavailable') + } + const start = text.data.indexOf(marker) + if (start === -1) { + throw new Error('Prompt end marker is unavailable') + } + const range = document.createRange() + range.setStart(text, start) + range.setEnd(text, start + marker.length) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + const scrollContainer = element.closest('[role="tabpanel"]') + if (!(scrollContainer instanceof HTMLElement)) { + throw new Error('Automation overview scroll container is unavailable') + } + scrollContainer.scrollTop += + range.getBoundingClientRect().bottom - scrollContainer.getBoundingClientRect().bottom + 16 + await new Promise(requestAnimationFrame) + const markerRect = range.getBoundingClientRect() + const containerRect = scrollContainer.getBoundingClientRect() + return { + selectedText: selection?.toString(), + markerTop: markerRect.top, + markerBottom: markerRect.bottom, + visibleTop: containerRect.top, + visibleBottom: containerRect.bottom + } + }, END_MARKER) + expect(markerProof.selectedText).toBe(END_MARKER) + expect(markerProof.markerTop).toBeGreaterThanOrEqual(markerProof.visibleTop) + expect(markerProof.markerBottom).toBeLessThanOrEqual(markerProof.visibleBottom) +}) From 95633a78834ce6182a503a079aeafe7e15feba8b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:18:39 -0700 Subject: [PATCH 02/12] Fix stale task-source flashes in new workspace input (#16145) * fix(new-workspace): prevent stale GitHub URL selection * fix(new-workspace): guard all task URL transitions * test(e2e): make task URL frame proof runner-safe * fix(new-workspace): guard Enter during task URL lookup --- .../new-workspace/SmartWorkspaceNameField.tsx | 27 +- .../smart-workspace-source-results.test.ts | 33 ++ .../smart-workspace-source-results.ts | 80 ++-- ...smart-workspace-url-source-results.test.ts | 210 +++++++++++ .../smart-workspace-url-source-results.ts | 126 +++++++ .../github-url-smart-input-transition.spec.ts | 351 ++++++++++++++++++ 6 files changed, 794 insertions(+), 33 deletions(-) create mode 100644 src/shared/new-workspace/smart-workspace-url-source-results.test.ts create mode 100644 src/shared/new-workspace/smart-workspace-url-source-results.ts create mode 100644 tests/e2e/github-url-smart-input-transition.spec.ts diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 58e771477dd..47040268575 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -61,6 +61,7 @@ import { getVisibleBranchResults, getVisibleHeldProviderResults, isBlockingJiraUrlIntent, + isBlockingTaskUrlResolution, type SmartNameMode, type SmartWorkspaceSourceRow } from './smart-workspace-source-results' @@ -639,6 +640,20 @@ export default function SmartWorkspaceNameField({ () => (sourceQueryWithinLimit ? parseGitHubIssueOrPRLink(debouncedQuery) : null), [debouncedQuery, sourceQueryWithinLimit] ) + const githubUrlIntent = useMemo( + () => + isSmartWorkspaceSourceQueryWithinLimit(value) && (mode === 'smart' || mode === 'github') + ? parseGitHubIssueOrPRLink(value) + : null, + [mode, value] + ) + const gitlabUrlIntent = useMemo( + () => + isSmartWorkspaceSourceQueryWithinLimit(value) && (mode === 'smart' || mode === 'gitlab') + ? parseGitLabIssueOrMRLink(value) + : null, + [mode, value] + ) const linearUrlIntent = useMemo( () => parseBoundedSmartWorkspaceLinearIssueUrlIntent(value), [value] @@ -1270,12 +1285,14 @@ export default function SmartWorkspaceNameField({ value, debouncedQuery }), + githubUrlIntent, gitlabAvailable: gitlabSourceAvailable, gitlabItems: getVisibleHeldProviderResults({ items: gitlabItems, value, debouncedQuery }), + gitlabUrlIntent, jiraIntent: jiraSource.intent, jiraIssue: jiraSource.issue, jiraIssues: getVisibleHeldProviderResults({ @@ -1302,8 +1319,10 @@ export default function SmartWorkspaceNameField({ branchResultsSource, debouncedQuery, githubItems, + githubUrlIntent, gitlabSourceAvailable, gitlabItems, + gitlabUrlIntent, jiraSource.accountChoices, jiraSource.intent, jiraSource.issue, @@ -1369,6 +1388,12 @@ export default function SmartWorkspaceNameField({ linearAvailable && sourceIntent !== 'linear' && (linearLoading || settledLinearUrlQuery !== linearQuery.trim()) + const blockingTaskUrlResolution = isBlockingTaskUrlResolution({ + sourceIntent: sourceIntent === 'github' || sourceIntent === 'gitlab' ? sourceIntent : null, + isQueryStale, + githubLoading, + gitlabLoading + }) const resolvedCommandValue = resolveSmartWorkspaceCommandValue({ currentValue: commandValue, @@ -1975,7 +2000,7 @@ export default function SmartWorkspaceNameField({ handleEmojiSelect(selectedEmojiSuggestion) return } - if (unresolvedLinearUrlIntent) { + if (unresolvedLinearUrlIntent || blockingTaskUrlResolution) { event.preventDefault() return } diff --git a/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts b/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts index 780800bb9a4..e78b6d5d76c 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts @@ -7,6 +7,7 @@ import { getVisibleBranchResults, getVisibleHeldProviderResults, isBlockingJiraUrlIntent, + isBlockingTaskUrlResolution, shouldHoldSourceResultsForQuery } from './smart-workspace-source-results' import { @@ -684,6 +685,38 @@ describe('Linear issue source input', () => { }) }) +describe('Task URL Enter protection', () => { + it('blocks Enter while a pasted GitHub or GitLab URL is resolving', () => { + expect( + isBlockingTaskUrlResolution({ + sourceIntent: 'github', + isQueryStale: true, + githubLoading: false, + gitlabLoading: false + }) + ).toBe(true) + expect( + isBlockingTaskUrlResolution({ + sourceIntent: 'gitlab', + isQueryStale: false, + githubLoading: false, + gitlabLoading: true + }) + ).toBe(true) + }) + + it('keeps the workspace-name fallback after lookup settles without a match', () => { + expect( + isBlockingTaskUrlResolution({ + sourceIntent: 'github', + isQueryStale: false, + githubLoading: false, + gitlabLoading: false + }) + ).toBe(false) + }) +}) + describe('Jira issue search', () => { it('keeps valid Jira URL intent blocking in both Smart and Jira modes', () => { const firstUrl = 'https://company.atlassian.net/browse/ORCA-1' diff --git a/src/shared/new-workspace/smart-workspace-source-results.ts b/src/shared/new-workspace/smart-workspace-source-results.ts index 40de888f150..39bd8ae3b54 100644 --- a/src/shared/new-workspace/smart-workspace-source-results.ts +++ b/src/shared/new-workspace/smart-workspace-source-results.ts @@ -5,10 +5,11 @@ import type { LinearIssue } from '../linear/issue-types' import type { LinearCollectionResult } from '../linear/workspace-types' import type { BaseRefSearchResult } from '../repo-types' import { JIRA_ISSUE_KEY_PATTERN, parseJiraIssueUrl } from '../jira-issue-url' +import type { GitHubIssueOrPRLink } from '../github/links' import { - isSmartWorkspaceLinearIssueIntentMatch, - parseBoundedSmartWorkspaceLinearIssueUrlIntent -} from './smart-workspace-linear-intent' + buildSmartWorkspaceUrlSourceRows, + type SmartWorkspaceGitLabUrlIntent +} from './smart-workspace-url-source-results' import { isSmartWorkspaceSourceQueryWithinLimit } from './smart-workspace-source-query' export { @@ -59,10 +60,31 @@ export function isBlockingJiraUrlIntent(mode: SmartNameMode, value: string): boo return (mode === 'smart' || mode === 'jira') && parseJiraIssueUrl(value) !== null } +export function isBlockingTaskUrlResolution({ + sourceIntent, + isQueryStale, + githubLoading, + gitlabLoading +}: { + sourceIntent: 'github' | 'gitlab' | null + isQueryStale: boolean + githubLoading: boolean + gitlabLoading: boolean +}): boolean { + if (sourceIntent === null) { + return false + } + return isQueryStale || (sourceIntent === 'github' ? githubLoading : gitlabLoading) +} + function toJiraSourceRow(issue: JiraIssue): SmartWorkspaceSourceRow { return { kind: 'jira', value: `jira-${issue.siteId ?? ''}-${issue.key}`, issue } } +function toGitHubSourceRow(item: GitHubWorkItem): SmartWorkspaceSourceRow { + return { kind: 'github', value: `github-${item.repoId}-${item.type}-${item.number}`, item } +} + export function getBranchSearchRequest({ branchesEnabled, disabled, @@ -186,8 +208,10 @@ export function shouldHoldSourceResultsForQuery({ export function buildSmartWorkspaceSourceRows({ branches, githubItems, + githubUrlIntent, gitlabAvailable, gitlabItems, + gitlabUrlIntent, jiraIntent = false, jiraIssue, jiraIssues = [], @@ -200,8 +224,10 @@ export function buildSmartWorkspaceSourceRows({ }: { branches: BaseRefSearchResult[] githubItems: GitHubWorkItem[] + githubUrlIntent?: GitHubIssueOrPRLink | null gitlabAvailable: boolean gitlabItems: GitLabWorkItem[] + gitlabUrlIntent?: SmartWorkspaceGitLabUrlIntent | null jiraIntent?: boolean jiraIssue?: JiraIssue | null jiraIssues?: JiraIssue[] @@ -219,34 +245,30 @@ export function buildSmartWorkspaceSourceRows({ if (!isSmartWorkspaceSourceQueryWithinLimit(value)) { return [] } - const trimmed = value.trim() - const nextRows: SmartWorkspaceSourceRow[] = [] const resolvedLinearIssues = Array.isArray(linearIssues) ? linearIssues : Array.isArray(linearIssues?.items) ? linearIssues.items : [] - const linearUrlIntent = parseBoundedSmartWorkspaceLinearIssueUrlIntent(trimmed) - if ( - linearUrlIntentOwnsResults && - linearAvailable && - linearUrlIntent && - (mode === 'smart' || mode === 'linear') - ) { - const linearRows = resolvedLinearIssues - .filter((issue) => isSmartWorkspaceLinearIssueIntentMatch(linearUrlIntent, issue)) - .map((issue) => ({ - kind: 'linear' as const, - value: `linear-${issue.id}`, - issue - })) - .slice(0, resultLimit) - // Why: keep "use as workspace name" available; sourceIntent focuses the issue. - if (trimmed && mode === 'smart') { - return [{ kind: 'use-name' as const, value: 'use-name', name: trimmed }, ...linearRows] - } - return linearRows + // Why: a full task URL is unambiguous, so unrelated held rows must never remain selectable. + const urlSourceRows = buildSmartWorkspaceUrlSourceRows({ + githubItems, + githubUrlIntent, + gitlabAvailable, + gitlabItems, + gitlabUrlIntent, + linearAvailable, + linearIssues: resolvedLinearIssues, + linearUrlIntentOwnsResults, + mode, + resultLimit, + value + }) + if (urlSourceRows !== null) { + return urlSourceRows } + const trimmed = value.trim() + const nextRows: SmartWorkspaceSourceRow[] = [] if (trimmed && mode === 'smart') { // Why: stable cmdk value — embedding the query remounted the row every keystroke. nextRows.push({ kind: 'use-name', value: 'use-name', name: trimmed }) @@ -255,13 +277,7 @@ export function buildSmartWorkspaceSourceRows({ return nextRows } if (mode === 'smart' || mode === 'github') { - nextRows.push( - ...githubItems.map((item) => ({ - kind: 'github' as const, - value: `github-${item.repoId}-${item.type}-${item.number}`, - item - })) - ) + nextRows.push(...githubItems.map(toGitHubSourceRow)) } if (gitlabAvailable && (mode === 'smart' || mode === 'gitlab')) { nextRows.push( diff --git a/src/shared/new-workspace/smart-workspace-url-source-results.test.ts b/src/shared/new-workspace/smart-workspace-url-source-results.test.ts new file mode 100644 index 00000000000..83a47854545 --- /dev/null +++ b/src/shared/new-workspace/smart-workspace-url-source-results.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import { buildSmartWorkspaceUrlSourceRows } from './smart-workspace-url-source-results' + +describe('smart workspace URL-owned source rows', () => { + it('hides unrelated held results while a full GitHub URL resolves', () => { + const targetUrl = 'https://github.com/acme/widgets/issues/42' + const staleItem = { + id: 'issue-17', + type: 'issue' as const, + number: 17, + title: 'Wrong cached issue', + state: 'open' as const, + url: 'https://github.com/acme/widgets/issues/17', + labels: [], + updatedAt: '2026-08-01T00:00:00.000Z', + author: 'octocat', + repoId: 'repo-1' + } + + expect( + buildSmartWorkspaceUrlSourceRows({ + mode: 'smart', + value: targetUrl, + githubUrlIntent: { + slug: { owner: 'acme', repo: 'widgets', host: 'github.com' }, + type: 'issue', + number: 42 + }, + githubItems: [ + staleItem, + { + ...staleItem, + id: 'same-number-other-repo', + number: 42, + url: 'https://github.com/acme/other-repo/issues/42' + }, + { + ...staleItem, + id: 'same-number-pull-request', + type: 'pr', + number: 42, + url: 'https://github.com/acme/widgets/pull/42' + } + ], + gitlabItems: [], + linearIssues: [], + gitlabAvailable: false, + linearAvailable: false, + linearUrlIntentOwnsResults: true, + resultLimit: 12 + }) + ).toEqual([{ kind: 'use-name', value: 'use-name', name: targetUrl }]) + }) + + it('shows only the exact GitHub URL result after lookup settles', () => { + const targetUrl = 'https://github.com/acme/widgets/issues/42' + const exactItem = { + id: 'issue-42', + type: 'issue' as const, + number: 42, + title: 'Exact pasted issue', + state: 'open' as const, + url: targetUrl, + labels: [], + updatedAt: '2026-08-02T00:00:00.000Z', + author: 'octocat', + repoId: 'repo-1' + } + + expect( + buildSmartWorkspaceUrlSourceRows({ + mode: 'smart', + value: targetUrl, + githubUrlIntent: { + slug: { owner: 'ACME', repo: 'Widgets', host: 'GitHub.com' }, + type: 'issue', + number: 42 + }, + githubItems: [ + { ...exactItem, id: 'issue-17', number: 17, url: `${targetUrl.slice(0, -2)}17` }, + exactItem + ], + gitlabItems: [], + linearIssues: [], + gitlabAvailable: false, + linearAvailable: false, + linearUrlIntentOwnsResults: true, + resultLimit: 12 + }) + ).toEqual([ + { kind: 'use-name', value: 'use-name', name: targetUrl }, + { kind: 'github', value: 'github-repo-1-issue-42', item: exactItem } + ]) + }) + + it('hides unrelated held results while a full GitLab URL resolves', () => { + const targetUrl = 'https://gitlab.example.test/acme/widgets/-/issues/42' + const staleItem = { + id: 'mr-17', + type: 'mr' as const, + number: 17, + title: 'Wrong cached merge request', + state: 'opened' as const, + url: 'https://gitlab.example.test/acme/widgets/-/merge_requests/17', + labels: [], + updatedAt: '2026-08-01T00:00:00.000Z', + author: 'gitlab-user', + repoId: 'repo-1' + } + + expect( + buildSmartWorkspaceUrlSourceRows({ + mode: 'smart', + value: targetUrl, + gitlabUrlIntent: { + slug: { host: 'gitlab.example.test', path: 'acme/widgets' }, + type: 'issue', + number: 42 + }, + githubItems: [], + gitlabItems: [ + staleItem, + { + ...staleItem, + id: 'same-number-other-project', + type: 'issue', + number: 42, + url: 'https://gitlab.example.test/acme/other/-/issues/42' + }, + { + ...staleItem, + id: 'same-project-other-host', + type: 'issue', + number: 42, + url: 'https://gitlab.other.test/acme/widgets/-/issues/42' + } + ], + linearIssues: [], + gitlabAvailable: true, + linearAvailable: false, + linearUrlIntentOwnsResults: true, + resultLimit: 12 + }) + ).toEqual([{ kind: 'use-name', value: 'use-name', name: targetUrl }]) + }) + + it('shows only the exact GitLab URL result after lookup settles', () => { + const targetUrl = 'https://gitlab.example.test/acme/widgets/-/issues/42' + const exactItem = { + id: 'issue-42', + type: 'issue' as const, + number: 42, + title: 'Exact pasted issue', + state: 'opened' as const, + url: targetUrl, + labels: [], + updatedAt: '2026-08-02T00:00:00.000Z', + author: 'gitlab-user', + repoId: 'repo-1' + } + + expect( + buildSmartWorkspaceUrlSourceRows({ + mode: 'smart', + value: targetUrl, + gitlabUrlIntent: { + slug: { host: 'GITLAB.EXAMPLE.TEST', path: 'Acme/Widgets' }, + type: 'issue', + number: 42 + }, + githubItems: [], + gitlabItems: [ + { + ...exactItem, + id: 'issue-17', + number: 17, + url: 'https://gitlab.example.test/acme/widgets/-/issues/17' + }, + exactItem + ], + linearIssues: [], + gitlabAvailable: true, + linearAvailable: false, + linearUrlIntentOwnsResults: true, + resultLimit: 12 + }) + ).toEqual([ + { kind: 'use-name', value: 'use-name', name: targetUrl }, + { kind: 'gitlab', value: 'gitlab-repo-1-issue-42', item: exactItem } + ]) + }) + + it('hides held provider rows when a Linear URL is recognized but disconnected', () => { + const issueUrl = 'https://linear.app/stably/issue/STA-4084/restore-shell-integration' + + expect( + buildSmartWorkspaceUrlSourceRows({ + mode: 'smart', + value: issueUrl, + githubItems: [{ repoId: 'repo-1', type: 'issue', number: 17 } as never], + gitlabItems: [{ repoId: 'repo-1', type: 'mr', number: 17 } as never], + linearIssues: [], + gitlabAvailable: true, + linearAvailable: false, + linearUrlIntentOwnsResults: true, + resultLimit: 12 + }) + ).toEqual([{ kind: 'use-name', value: 'use-name', name: issueUrl }]) + }) +}) diff --git a/src/shared/new-workspace/smart-workspace-url-source-results.ts b/src/shared/new-workspace/smart-workspace-url-source-results.ts new file mode 100644 index 00000000000..a1aa75b82f0 --- /dev/null +++ b/src/shared/new-workspace/smart-workspace-url-source-results.ts @@ -0,0 +1,126 @@ +import { parseGitHubIssueOrPRLink, type GitHubIssueOrPRLink } from '../github/links' +import { githubRepoIdentityKey } from '../github/repository-identity-key' +import type { GitHubWorkItem } from '../github/work-item-types' +import type { GitLabWorkItem } from '../gitlab-types' +import type { LinearIssue } from '../linear/issue-types' +import { parseGitLabIssueOrMRLink } from './gitlab-links' +import { + isSmartWorkspaceLinearIssueIntentMatch, + parseBoundedSmartWorkspaceLinearIssueUrlIntent +} from './smart-workspace-linear-intent' + +export type SmartWorkspaceGitLabUrlIntent = NonNullable> + +type SmartWorkspaceUrlSourceMode = + | 'smart' + | 'github' + | 'gitlab' + | 'branches' + | 'linear' + | 'jira' + | 'text' + +export type SmartWorkspaceUrlSourceRow = + | { kind: 'use-name'; value: 'use-name'; name: string } + | { kind: 'github'; value: string; item: GitHubWorkItem } + | { kind: 'gitlab'; value: string; item: GitLabWorkItem } + | { kind: 'linear'; value: string; issue: LinearIssue } + +function toGitHubSourceRow(item: GitHubWorkItem): SmartWorkspaceUrlSourceRow { + return { kind: 'github', value: `github-${item.repoId}-${item.type}-${item.number}`, item } +} + +function withSmartNameFallback( + mode: SmartWorkspaceUrlSourceMode, + name: string, + rows: SmartWorkspaceUrlSourceRow[] +): SmartWorkspaceUrlSourceRow[] { + return name && mode === 'smart' ? [{ kind: 'use-name', value: 'use-name', name }, ...rows] : rows +} + +function isGitHubLinkIntentMatch(intent: GitHubIssueOrPRLink, item: GitHubWorkItem): boolean { + const itemLink = parseGitHubIssueOrPRLink(item.url) + return ( + itemLink !== null && + itemLink.type === intent.type && + itemLink.number === intent.number && + githubRepoIdentityKey(itemLink.slug) === githubRepoIdentityKey(intent.slug) + ) +} + +function isGitLabLinkIntentMatch( + intent: SmartWorkspaceGitLabUrlIntent, + item: GitLabWorkItem +): boolean { + const itemLink = parseGitLabIssueOrMRLink(item.url) + return ( + itemLink !== null && + itemLink.type === intent.type && + itemLink.number === intent.number && + itemLink.slug.host.toLowerCase() === intent.slug.host.toLowerCase() && + itemLink.slug.path.toLowerCase() === intent.slug.path.toLowerCase() + ) +} + +export function buildSmartWorkspaceUrlSourceRows({ + githubItems, + githubUrlIntent, + gitlabAvailable, + gitlabItems, + gitlabUrlIntent, + linearAvailable, + linearIssues, + linearUrlIntentOwnsResults, + mode, + resultLimit, + value +}: { + githubItems: GitHubWorkItem[] + githubUrlIntent?: GitHubIssueOrPRLink | null + gitlabAvailable: boolean + gitlabItems: GitLabWorkItem[] + gitlabUrlIntent?: SmartWorkspaceGitLabUrlIntent | null + linearAvailable: boolean + linearIssues: LinearIssue[] + linearUrlIntentOwnsResults: boolean + mode: SmartWorkspaceUrlSourceMode + resultLimit: number + value: string +}): SmartWorkspaceUrlSourceRow[] | null { + const trimmed = value.trim() + if (githubUrlIntent && (mode === 'smart' || mode === 'github')) { + const rows = githubItems + .filter((item) => isGitHubLinkIntentMatch(githubUrlIntent, item)) + .map(toGitHubSourceRow) + .slice(0, resultLimit) + return withSmartNameFallback(mode, trimmed, rows) + } + if (gitlabUrlIntent && (mode === 'smart' || mode === 'gitlab')) { + const rows = gitlabAvailable + ? gitlabItems + .filter((item) => isGitLabLinkIntentMatch(gitlabUrlIntent, item)) + .map((item) => ({ + kind: 'gitlab' as const, + value: `gitlab-${item.repoId}-${item.type}-${item.number}`, + item + })) + .slice(0, resultLimit) + : [] + return withSmartNameFallback(mode, trimmed, rows) + } + const linearUrlIntent = parseBoundedSmartWorkspaceLinearIssueUrlIntent(trimmed) + if (linearUrlIntentOwnsResults && linearUrlIntent && (mode === 'smart' || mode === 'linear')) { + const rows = linearAvailable + ? linearIssues + .filter((issue) => isSmartWorkspaceLinearIssueIntentMatch(linearUrlIntent, issue)) + .map((issue) => ({ + kind: 'linear' as const, + value: `linear-${issue.id}`, + issue + })) + .slice(0, resultLimit) + : [] + return withSmartNameFallback(mode, trimmed, rows) + } + return null +} diff --git a/tests/e2e/github-url-smart-input-transition.spec.ts b/tests/e2e/github-url-smart-input-transition.spec.ts new file mode 100644 index 00000000000..637620eaa89 --- /dev/null +++ b/tests/e2e/github-url-smart-input-transition.spec.ts @@ -0,0 +1,351 @@ +import type { ElectronApplication, Locator, Page } from '@stablyai/playwright-test' +import type { GitHubWorkItem } from '../../src/shared/github/work-item-types' +import type { GitLabWorkItem } from '../../src/shared/gitlab-types' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const TARGET_URL = 'https://github.com/stablyai/orca/issues/4242' +const WRONG_TITLE = 'Wrong cached issue' +const TARGET_TITLE = 'Exact pasted issue' +const GITLAB_TARGET_URL = 'https://gitlab.example.test/stablyai/orca/-/merge_requests/4242' +const GITLAB_WRONG_TITLE = 'Wrong cached merge request' +const GITLAB_TARGET_TITLE = 'Exact pasted merge request' +const MIN_PASTED_FRAMES = 2 +const TRANSITION_FRAME_LIMIT = 600 + +const WRONG_ITEM: GitHubWorkItem = { + id: 'issue-17', + type: 'issue', + number: 17, + title: WRONG_TITLE, + state: 'open', + url: 'https://github.com/stablyai/orca/issues/17', + labels: [], + updatedAt: '2026-08-01T00:00:00.000Z', + author: 'e2e', + repoId: 'e2e-repo' +} + +const TARGET_ITEM: GitHubWorkItem = { + ...WRONG_ITEM, + id: 'issue-4242', + number: 4242, + title: TARGET_TITLE, + url: TARGET_URL, + updatedAt: '2026-08-02T00:00:00.000Z' +} + +const GITLAB_WRONG_ITEM: GitLabWorkItem = { + id: 'mr-17', + type: 'mr', + number: 17, + title: GITLAB_WRONG_TITLE, + state: 'opened', + url: 'https://gitlab.example.test/stablyai/orca/-/merge_requests/17', + labels: [], + updatedAt: '2026-08-01T00:00:00.000Z', + author: 'e2e', + repoId: 'e2e-repo' +} + +const GITLAB_TARGET_ITEM: GitLabWorkItem = { + ...GITLAB_WRONG_ITEM, + id: 'mr-4242', + number: 4242, + title: GITLAB_TARGET_TITLE, + url: GITLAB_TARGET_URL, + updatedAt: '2026-08-02T00:00:00.000Z' +} + +type TransitionFrame = { + value: string + wrongVisible: boolean + wrongSelected: boolean + targetVisible: boolean + targetSelected: boolean +} + +function pasteChord(): string { + return process.platform === 'darwin' ? 'Meta+V' : 'Control+V' +} + +async function startTransitionCapture( + page: Page, + frameKey: string, + wrongTitle: string, + targetTitle: string +): Promise { + await page.evaluate( + ({ frameKey, frameLimit, wrongTitle, targetTitle }) => { + const frames: TransitionFrame[] = [] + const capture = (): void => { + const input = document.querySelector('[data-workspace-name-input="true"]') + const options = [...document.querySelectorAll('[role="option"]')] + const wrong = options.find((option) => option.textContent?.includes(wrongTitle)) + const target = options.find((option) => option.textContent?.includes(targetTitle)) + frames.push({ + value: input?.value ?? '', + wrongVisible: Boolean(wrong && wrong.getClientRects().length > 0), + wrongSelected: wrong?.dataset.selected === 'true', + targetVisible: Boolean(target && target.getClientRects().length > 0), + targetSelected: target?.dataset.selected === 'true' + }) + if (frames.length < frameLimit) { + requestAnimationFrame(capture) + } + } + Reflect.set(window, frameKey, frames) + capture() + }, + { frameKey, frameLimit: TRANSITION_FRAME_LIMIT, wrongTitle, targetTitle } + ) +} + +async function readTransitionFrames(page: Page, frameKey: string): Promise { + return page.evaluate((key) => Reflect.get(window, key) as TransitionFrame[], frameKey) +} + +async function expectLookupHeldWithoutStaleRow( + page: Page, + frameKey: string, + targetUrl: string, + wrongOption: Locator, + targetOption: Locator +): Promise { + await expect + .poll(async () => { + const frames = await readTransitionFrames(page, frameKey) + return frames.filter((frame) => frame.value === targetUrl).length + }) + .toBeGreaterThanOrEqual(MIN_PASTED_FRAMES) + await expect(wrongOption).toHaveCount(0) + await expect(targetOption).toHaveCount(0) +} + +async function expectExactTargetAfterLookup( + page: Page, + frameKey: string, + targetUrl: string, + targetOption: Locator +): Promise { + await expect(targetOption).toBeVisible() + await expect(targetOption).toHaveAttribute('data-selected', 'true') + await expect + .poll(async () => { + const frames = await readTransitionFrames(page, frameKey) + return frames.some((frame) => frame.targetVisible && frame.targetSelected) + }) + .toBe(true) + + const frames = await readTransitionFrames(page, frameKey) + const pastedFrames = frames.filter((frame) => frame.value === targetUrl) + expect(frames.some((frame) => frame.wrongVisible)).toBe(true) + expect(pastedFrames.length).toBeGreaterThanOrEqual(MIN_PASTED_FRAMES) + expect(pastedFrames.every((frame) => !frame.wrongVisible && !frame.wrongSelected)).toBe(true) + expect(pastedFrames.some((frame) => frame.targetVisible && frame.targetSelected)).toBe(true) +} + +async function installHeldGitHubLookup( + electronApp: ElectronApplication, + page: Page +): Promise { + await electronApp.evaluate(({ ipcMain }, targetItem) => { + const fixture = globalThis as unknown as { + __githubUrlLookupStarted?: boolean + __releaseGitHubUrlLookup?: () => void + } + fixture.__githubUrlLookupStarted = false + ipcMain.removeHandler('gh:workItemByOwnerRepo') + ipcMain.handle('gh:workItemByOwnerRepo', () => { + fixture.__githubUrlLookupStarted = true + return new Promise((resolve) => { + fixture.__releaseGitHubUrlLookup = () => resolve(targetItem) + }) + }) + }, TARGET_ITEM) + await page.evaluate((wrongItem) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + store.setState({ + getCachedWorkItems: () => [wrongItem], + fetchWorkItems: async () => [wrongItem], + fetchWorkItemsAcrossRepos: async () => ({ + items: [wrongItem], + failedCount: 0, + githubUnavailable: false + }) + }) + }, WRONG_ITEM) +} + +async function releaseGitHubLookup(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(() => { + const fixture = globalThis as unknown as { __releaseGitHubUrlLookup?: () => void } + if (!fixture.__releaseGitHubUrlLookup) { + throw new Error('GitHub lookup is not held') + } + fixture.__releaseGitHubUrlLookup() + }) +} + +async function installHeldGitLabLookup( + electronApp: ElectronApplication, + page: Page +): Promise { + await electronApp.evaluate( + ({ ipcMain }, { wrongItem, targetItem }) => { + const fixture = globalThis as unknown as { + __gitlabUrlLookupStarted?: boolean + __releaseGitLabUrlLookup?: () => void + } + fixture.__gitlabUrlLookupStarted = false + ipcMain.removeHandler('gitlab:listMRs') + ipcMain.handle('gitlab:listMRs', () => ({ + items: [wrongItem], + page: 1, + perPage: 12, + totalCount: 1, + totalPages: 1 + })) + ipcMain.removeHandler('gitlab:workItemByPath') + ipcMain.handle('gitlab:workItemByPath', () => { + fixture.__gitlabUrlLookupStarted = true + return new Promise((resolve) => { + fixture.__releaseGitLabUrlLookup = () => resolve(targetItem) + }) + }) + }, + { wrongItem: GITLAB_WRONG_ITEM, targetItem: GITLAB_TARGET_ITEM } + ) + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + if (!state.preflightStatusContextKey) { + throw new Error('preflight context is not ready') + } + store.setState({ + preflightStatus: { + git: state.preflightStatus?.git ?? { installed: true }, + gh: state.preflightStatus?.gh ?? { installed: true, authenticated: true }, + glab: { installed: true, authenticated: true } + }, + preflightStatusChecked: true + }) + }) +} + +async function releaseGitLabLookup(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(() => { + const fixture = globalThis as unknown as { __releaseGitLabUrlLookup?: () => void } + if (!fixture.__releaseGitLabUrlLookup) { + throw new Error('GitLab lookup is not held') + } + fixture.__releaseGitLabUrlLookup() + }) +} + +test('a pasted GitHub URL never selects a stale cached issue', async ({ + electronApp, + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await installHeldGitHubLookup(electronApp, orcaPage) + + await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() + const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + const input = dialog.locator('[data-workspace-name-input="true"]') + await expect(input).toBeVisible() + await input.click() + + const wrongOption = orcaPage.getByRole('option', { name: `#17 ${WRONG_TITLE}`, exact: true }) + const targetOption = orcaPage.getByRole('option', { + name: `#4242 ${TARGET_TITLE}`, + exact: true + }) + await expect(wrongOption).toBeVisible() + + const frameKey = '__githubUrlTransitionFrames' + await startTransitionCapture(orcaPage, frameKey, WRONG_TITLE, TARGET_TITLE) + + await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), TARGET_URL) + await input.focus() + await orcaPage.keyboard.press(pasteChord()) + await expect + .poll(() => + electronApp.evaluate(() => { + const fixture = globalThis as unknown as { __githubUrlLookupStarted?: boolean } + return fixture.__githubUrlLookupStarted === true + }) + ) + .toBe(true) + await expectLookupHeldWithoutStaleRow(orcaPage, frameKey, TARGET_URL, wrongOption, targetOption) + + await releaseGitHubLookup(electronApp) + await expectExactTargetAfterLookup(orcaPage, frameKey, TARGET_URL, targetOption) + + await testInfo.attach('github-url-smart-input-fixed.png', { + body: await orcaPage.screenshot(), + contentType: 'image/png' + }) +}) + +test('a pasted GitLab URL never selects a stale cached merge request', async ({ + electronApp, + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await installHeldGitLabLookup(electronApp, orcaPage) + + await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() + const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + const input = dialog.locator('[data-workspace-name-input="true"]') + await expect(input).toBeVisible() + await input.click() + + const wrongOption = orcaPage.getByRole('option', { + name: `!17 ${GITLAB_WRONG_TITLE}`, + exact: true + }) + const targetOption = orcaPage.getByRole('option', { + name: `!4242 ${GITLAB_TARGET_TITLE}`, + exact: true + }) + await expect(wrongOption).toBeVisible() + + const frameKey = '__gitlabUrlTransitionFrames' + await startTransitionCapture(orcaPage, frameKey, GITLAB_WRONG_TITLE, GITLAB_TARGET_TITLE) + + await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), GITLAB_TARGET_URL) + await input.focus() + await orcaPage.keyboard.press(pasteChord()) + await expect + .poll(() => + electronApp.evaluate(() => { + const fixture = globalThis as unknown as { __gitlabUrlLookupStarted?: boolean } + return fixture.__gitlabUrlLookupStarted === true + }) + ) + .toBe(true) + await expectLookupHeldWithoutStaleRow( + orcaPage, + frameKey, + GITLAB_TARGET_URL, + wrongOption, + targetOption + ) + + await releaseGitLabLookup(electronApp) + await expectExactTargetAfterLookup(orcaPage, frameKey, GITLAB_TARGET_URL, targetOption) + + await testInfo.attach('gitlab-url-smart-input-fixed.png', { + body: await orcaPage.screenshot(), + contentType: 'image/png' + }) +}) From afd76a4df939e336180bd53611c5be8fa53d930b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:01:53 -0700 Subject: [PATCH 03/12] fix(terminal): preserve synchronized frames on reveal (#16026) --- config/reliability-gates.jsonc | 204 ++++++++- ...connection-hidden-backlog-snapshot.test.ts | 12 +- .../hidden-output-restore-snapshot.ts | 8 + .../terminal-visibility-resume.test.ts | 59 ++- .../terminal-visibility-resume.ts | 30 +- .../terminal-webgl-atlas-recovery.test.ts | 25 +- .../terminal-webgl-atlas-recovery.ts | 6 - ...e-global-effects-visibility-resume.test.ts | 16 +- .../use-terminal-window-wake-recovery.test.ts | 136 +++++- .../use-terminal-window-wake-recovery.ts | 68 +++ .../pane-manager-registry.test.ts | 46 +- .../lib/pane-manager/pane-manager-registry.ts | 18 +- .../src/lib/pane-manager/pane-manager.ts | 13 +- .../pane-manager/pane-rendering-control.ts | 14 + .../lib/pane-manager/pane-reveal-repaint.ts | 16 +- .../pane-manager/pane-webgl-renderer.test.ts | 112 ++++- .../lib/pane-manager/pane-webgl-renderer.ts | 101 ++++- .../terminal-canvas-dpr-repair.test.ts | 38 +- .../terminal-canvas-dpr-repair.ts | 29 +- .../terminal-render-pause-release.test.ts | 145 ++++++- .../terminal-render-pause-release.ts | 112 +++++ ...erminal-reveal-paused-render-repro.spec.ts | 392 +++++++++++++++++- 22 files changed, 1481 insertions(+), 119 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 12b937b187f..97711a43088 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2674,7 +2674,7 @@ "platform": "macos", "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "result": "failed", - "durationSeconds": 60, + "durationSeconds": 53, "summary": "The byte-identical final Electron oracle failed at its exact host/client identity barrier when the sole source delta replaced src/renderer/src/store/index.ts registration lines 110-113 with registerRuntimeHttpLinkBrowserOpener(null); no host-owned browser appeared within the deterministic 60-second bound." } ], @@ -12284,7 +12284,7 @@ "id": "terminal-render.pixel-refresh-repair", "title": "Forced repaint does not change revealed pane pixels", "maturity": "experimental", - "protection": "none", + "protection": "partial", "owner": "terminal-rendering", "layer": "electron-visual-golden", "surfaces": [ @@ -12296,47 +12296,207 @@ ], "platforms": ["macos", "linux", "windows"], "providers": ["local", "daemon"], - "coveredPlatforms": [], - "coveredProviders": [], - "coverageNotes": "Registered gap only; the live repro harness with a refresh-repair oracle exists but is not productized into the release-blocking terminal-rendering-golden suite.", + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "A headed macOS Electron test drives explicit destructive atlas recovery while DEC 2026 output is held, then compares compositor screenshots before and after a direct full-buffer present. The same renderer choreography is provider-local, but daemon, SSH, remote-runtime, Linux, and Windows GPU runs remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/7133", "https://github.com/stablyai/orca/pull/7054" ], - "invariant": "After any reveal, restore, or wake, the pixels a pane displays must equal what a forced full model-invalidating repaint would draw from the buffer; a material refresh-repair diff means stale rendering escaped, whatever the mechanism.", - "oracle": "Screenshot the revealed pane, force a model-invalidating full repaint, screenshot again, and assert the bounded pixel diff is immaterial; pair with a JS-level render-model-vs-buffer comparison. Compositor screenshots only, because WebGL canvas readback is blank with preserveDrawingBuffer disabled.", - "commands": [], - "testFiles": [], - "assertionRefs": [], - "evidenceRuns": [], + "invariant": "After proven renderer damage triggers destructive recovery, visible pixels equal a full model-invalidating present of the current buffer.", + "oracle": "Hold DEC 2026, invoke explicit atlas recovery, require an atlas clear, screenshot the pane, force a direct renderer present, and assert the bounded pixel diff is immaterial. Compositor screenshots only, because WebGL canvas readback is blank with preserveDrawingBuffer disabled.", + "commands": [ + "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts", + "ORCA_E2E_RECORD_VIDEO=1 pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --repeat-each=3" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts", + "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", + "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts", + "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts", + "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts", + "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts", + "tests/e2e/terminal-reveal-paused-render-repro.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", + "assertions": [ + "deduplicates a deferred dpr retry and consumes it after repair succeeds", + "caps a deferred dpr transition at sixteen animation-frame retries" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "assertions": ["defers a heavy-reveal dpr present until the shared atlas recovery"] + }, + { + "file": "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts", + "assertions": [ + "repairs when the canvas and renderer dimensions share a stale dpr cache", + "defers an unmeasurable WebGL canvas but accepts a renderer-less pane" + ] + }, + { + "file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts", + "assertions": ["@headful atlas recovery presents a synchronized-output WebGL frame"] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts", + "result": "passed", + "durationSeconds": 2, + "summary": "All 98 renderer contracts passed, including same-DPR zero-scan behavior, visible-only fanout, tri-state DPR repair, deduplicated bounded retry, heavy-reveal repair, and listener cleanup." + }, + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 60, + "summary": "Both headed oracles passed together. Explicit destructive atlas recovery presented the held synchronized-output buffer with zero pixels different from a direct renderer present; the test attached before/after screenshots plus pixel-diff metadata." + }, + { + "date": "2026-08-22", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_RECORD_VIDEO=1 pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --repeat-each=3", + "result": "passed", + "durationSeconds": 48, + "summary": "Three consecutive headed real-WebGL runs passed in 15.0-15.7 seconds each and emitted a 1920x1080 video per BrowserWindow." + } + ], "runtimeBudget": { - "p95Seconds": 300, - "scope": "release-blocking golden Electron visual test" + "p95Seconds": 120, + "scope": "focused prebuilt headed Electron visual test" }, "flakeHistory": { - "status": "not-started", - "evidence": "Runs nightly only while the per-platform diff threshold stabilizes; the destination is the release-blocking terminal-rendering-golden suite in release-cut.yml." + "status": "unknown", + "evidence": "Five local macOS headed runs passed, including one on the final branch state and three consecutive video-recorded repeats; CI soak history and cross-platform GPU runs are not yet available." }, "redGreenEvidence": { - "status": "missing", - "evidence": "Needs an intentionally fossilized render model (skip the model invalidation on reveal) proving the refresh-repair diff fires." + "status": "complete", + "evidence": "Without the forced synchronized-output present, the damaged compositor differed from a direct renderer present across 87.68% of pixels even though serialization already contained the new frame. With explicit destructive recovery, the same oracle passes the 1.5% material-diff threshold." }, "performanceBudget": { "required": true, - "evidence": "Soak-only; must not add product-path repaints beyond the existing reveal hardening." + "evidence": "Destructive recovery remains event-driven and limited to confirmed renderer-lifecycle boundaries. Gate-local contracts prove ordinary same-DPR resize performs zero pane scans; a true transition scans only the visible manager; unavailable canvas retry is deduplicated and capped at 16 frames; and heavy reveal adds one bounded visible-pane scan. The shared terminal-render.atlas-recovery-fanout contracts separately prove zero hidden-manager atlas clears or presents and a per-pane display:none retry capped at 16 frames." }, "promotionCriteria": [ - "Productize the existing stale-render repro harness with the refresh-repair oracle and failure artifacts.", "Establish a stable material-diff threshold across platforms via nightly runs.", "Promotion means membership in the release-blocking terminal-rendering-golden suite that release-cut.yml runs against every release tag; never PR-blocking.", - "Register the gate command as a direct spec-file invocation; the checker rejects --grep tag selectors, so do not reuse the existing --grep-based golden script as the command." + "Add Linux and Windows GPU evidence or retain those platform gaps explicitly." ], "knownGaps": [ - "No manifest command yet.", - "Buffer-clean, pixels-stale variants are otherwise invisible to every buffer oracle in the manifest." + "The deterministic recovery test disables xterm's one-second safety timeout so the held-frame damage remains stable until recovery.", + "The renderer-level frame injection isolates the paint failure; provider delivery and hidden-output snapshot restore have separate gates.", + "Linux, Windows, daemon, SSH, and remote-runtime live GPU evidence is not yet recorded." ], "demotionRule": "Drop from the golden suite back to nightly diagnostic if diff thresholds cannot be kept stable across CI runners." }, + { + "id": "terminal-render.synchronized-reveal-coherence", + "title": "Ordinary reveal preserves synchronized-output frame coherence", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "electron-visual-golden", + "surfaces": ["WebGL rendering", "tab reveal", "snapshot restore", "DEC 2026"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "A headed macOS Electron test uses a real xterm WebGL renderer and production tab reveal. It proves the coherent pre-hide canvas remains visible while DEC 2026 holds a newer buffer, then proves normal release and xterm's one-second watchdog publish complete frames. Renderer behavior is provider-neutral; live Linux, Windows, daemon, SSH, and remote-runtime GPU runs remain gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/7133", + "https://github.com/stablyai/orca/pull/7054" + ], + "invariant": "terminal-render.synchronized-reveal-coherence: an ordinary reveal never clears a healthy shared atlas or publishes a partially built DEC 2026 frame; it retains the last coherent canvas until release or xterm's bounded watchdog.", + "oracle": "Paint and capture a coherent frame, hide the real terminal tab, build a distinct full-screen frame while DEC 2026 is held, reveal through production, and require zero atlas clears plus pixel-equivalence with the coherent frame. Then release normally and through the one-second watchdog, requiring both compositor frames to match a direct renderer present.", + "commands": [ + "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts", + "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts", + "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts", + "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts", + "tests/e2e/terminal-reveal-paused-render-repro.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "assertions": [ + "schedules an atlas-preserving present on a light tab reveal", + "keeps heavy atlas recovery off a plain window refocus" + ] + }, + { + "file": "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts", + "assertions": ["preserves a synchronized frame on an ordinary reveal present"] + }, + { + "file": "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts", + "assertions": [ + "routes synchronized output through RenderService instead of the renderer", + "releases observer pause before requesting the synchronized frame" + ] + }, + { + "file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts", + "assertions": [ + "@headful reveal preserves the coherent frame until synchronized output releases" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 53, + "summary": "Both final-head headed oracles passed together. The coherent held frame differed by 73 of 1,404,288 pixels (0.0052%), ordinary reveal performed zero atlas clears, normal release differed from the direct present by 95 pixels (0.0068%), and watchdog release matched exactly. All three ratios passed the 1.5% compositor threshold." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "focused prebuilt headed Electron prevention and watchdog test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "One final watchdog-inclusive macOS headed run passed; CI soak history and cross-platform GPU runs are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Before the prevention change, the ordinary reveal oracle failed deterministically because it observed three atlas clears. After the change, reveal performs zero clears and the first visible frame remains pixel-equivalent to the coherent pre-hide frame." + }, + "performanceBudget": { + "required": true, + "evidence": "Ordinary reveal removes three shared-atlas clears and their global visible-manager presents. It adds one settled, event-driven full-viewport refresh request per revealed pane; DEC 2026 coalesces it and uses xterm's existing one-second watchdog. No polling, subprocess, provider call, PTY inventory, background-output work, listener, or unbounded retry was added; the display-none retry remains deduplicated and capped at 16 frames." + }, + "promotionCriteria": [ + "Accumulate stable macOS runs for both normal release and watchdog release.", + "Add Linux and Windows GPU evidence or retain those platform gaps explicitly.", + "Keep the destructive atlas-recovery oracle separate and passing." + ], + "knownGaps": [ + "The deterministic test injects full-screen marker frames through xterm rather than invoking a live model; genuine OpenCode full-window evidence separately proves the original corruption.", + "Linux, Windows, daemon, SSH, and remote-runtime live GPU evidence is not yet recorded.", + "The 1.5% compositor threshold tolerates cursor and shell-prompt timing noise; exact buffer content and atlas-clear counts are asserted separately." + ], + "demotionRule": "Demote if ordinary reveal clears the shared atlas, the held frame stops matching its coherent baseline, release/watchdog output diverges from a direct present, or the headed oracle cannot remain deterministic." + }, { "id": "pty-delivery.renderer-lifecycle-accounting-reset", "title": "Renderer lifecycle reset clears leaked PTY delivery accounting so surviving PTYs cannot freeze", diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts index c1df93dca25..a54c78f87de 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts @@ -33,14 +33,16 @@ const { scheduleRuntimeGraphSync, shouldSeedCacheTimerOnInitialTitle, toastInfo, - notifyCodexPaneBoundForStaleSweep + notifyCodexPaneBoundForStaleSweep, + presentPaneViewportPreservingSynchronizedOutput } = vi.hoisted(() => ({ resetAndRefreshAllTerminalWebglAtlases: vi.fn(), scheduleTerminalWebglAtlasRecovery: vi.fn(), scheduleRuntimeGraphSync: vi.fn(), shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() + notifyCodexPaneBoundForStaleSweep: vi.fn(), + presentPaneViewportPreservingSynchronizedOutput: vi.fn() })) let mockStoreState: StoreState @@ -57,6 +59,11 @@ vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ resetAndRefreshAllTerminalWebglAtlases })) +vi.mock('@/lib/pane-manager/pane-webgl-renderer', async (importOriginal) => ({ + ...(await importOriginal>()), + presentPaneViewportPreservingSynchronizedOutput +})) + vi.mock('./terminal-webgl-atlas-recovery', () => ({ scheduleTerminalWebglAtlasRecovery })) @@ -201,6 +208,7 @@ describe('connectPanePty', () => { expect.stringContaining('remote snapshot with hidden remote output'), expect.any(Function) ) + expect(presentPaneViewportPreservingSynchronizedOutput).toHaveBeenCalledWith(pane) disposable.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts index 8714adbd84b..076c9194934 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts @@ -11,6 +11,7 @@ import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' +import { presentPaneViewportPreservingSynchronizedOutput } from '@/lib/pane-manager/pane-webgl-renderer' import { buildMainModelSnapshotReplayWrites, hasPositiveTerminalDimensions, @@ -22,6 +23,7 @@ import { HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING } from './hidden-output-resto import { shouldWritePtyOutputForeground } from './foreground-output-scan' import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage' +import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -152,6 +154,12 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): session.recordRendererOrderedSeq(snapshot) recordTerminalOutput(session.pane.terminal) await waitForTerminalReplayWritesParsed(session.pane.terminal) + if (session.deps.isVisibleRef.current) { + presentPaneViewportPreservingSynchronizedOutput(session.pane) + recordTerminalFreezeBreadcrumb('stale-pixel-restore-present', { + paneId: session.pane.id + }) + } }, { shouldRestore: () => diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index 3b198054861..74af99a8e14 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -8,6 +8,10 @@ import { vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({ resetAndRefreshAllTerminalWebglAtlases: vi.fn() })) +const presentPaneViewport = vi.fn() +vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({ + presentPaneViewport: (pane: unknown) => presentPaneViewport(pane) +})) vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ flushTerminalOutput: vi.fn(), requestTerminalBacklogRecovery: vi.fn() @@ -21,10 +25,6 @@ vi.mock('./pane-helpers', () => ({ fitPanes: vi.fn(), focusActivePane: vi.fn() })) -const scheduleTabRevealWebglAtlasRecovery = vi.fn() -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTabRevealWebglAtlasRecovery: () => scheduleTabRevealWebglAtlasRecovery() -})) const flushDeferredPaneMetricOptionsIfMeasurable = vi.fn((_pane: unknown) => false) vi.mock('@/lib/pane-manager/pane-fit', () => ({ flushDeferredPaneMetricOptionsIfMeasurable: (pane: unknown) => @@ -77,18 +77,19 @@ function resumeArgs(manager: FakeManager, shouldUseLightTabResume: boolean) { describe('resumeTerminalVisibility reveal repaint', () => { beforeEach(() => { vi.clearAllMocks() + repairPaneWebglCanvasDprMismatch.mockReturnValue(false) }) - it('schedules a pane-scoped repaint on a light tab reveal', () => { + it('schedules an atlas-preserving present on a light tab reveal', () => { // The light path is the "click the tab that was not open" gesture: it has // no rendering resume or fit, so without this repaint a hidden-while- // working pane keeps compositing pre-hide pixels. const manager = createManager() resumeTerminalVisibility(resumeArgs(manager, true)) - expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1) expect(manager.resumeRendering).not.toHaveBeenCalled() - expect(scheduleTabRevealWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) it('captures native trim movement before enforcing viewport intent', async () => { @@ -147,6 +148,29 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(flushDeferredPaneMetricOptionsIfMeasurable).not.toHaveBeenCalled() }) + it('defers a heavy-reveal dpr present until the shared atlas recovery', async () => { + const pane = { terminal: {} } + const manager = createManager() + manager.getPanes.mockReturnValue([pane]) + repairPaneWebglCanvasDprMismatch.mockReturnValueOnce(true) + const { resetAndRefreshAllTerminalWebglAtlases } = vi.mocked( + await import('@/lib/pane-manager/pane-manager-registry') + ) + + resumeTerminalVisibility(resumeArgs(manager, false)) + + expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledWith(pane) + expect(presentPaneViewport).not.toHaveBeenCalled() + expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(1) + const atlasResetCallOrder = resetAndRefreshAllTerminalWebglAtlases.mock.invocationCallOrder[0] + if (atlasResetCallOrder === undefined) { + throw new Error('Shared atlas recovery call order missing') + } + expect(repairPaneWebglCanvasDprMismatch.mock.invocationCallOrder[0]).toBeLessThan( + atlasResetCallOrder + ) + }) + it('does not fit on a light tab reveal', () => { const manager = createManager() resumeTerminalVisibility(resumeArgs(manager, true)) @@ -193,6 +217,27 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(manager.fitAllPanes).not.toHaveBeenCalled() }) + it('repairs WebGL canvas backing-store dpr on window wake', () => { + // Clamshell undock: dpr changes while the pane stayed "visible" with a + // stale backing store; tab-reveal is not in the path. + const first = { terminal: { name: 'pane-a' } } + const second = { terminal: { name: 'pane-b' } } + const manager = createManager() + manager.getPanes.mockReturnValue([first, second]) + repairPaneWebglCanvasDprMismatch.mockReturnValueOnce(true) + + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: true, + clearGlyphAtlases: false + }) + + expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledTimes(2) + expect(repairPaneWebglCanvasDprMismatch).toHaveBeenNthCalledWith(1, first) + expect(repairPaneWebglCanvasDprMismatch).toHaveBeenNthCalledWith(2, second) + expect(presentPaneViewport).toHaveBeenCalledWith(first) + }) + it('latches viewport intent before refocus recovery flushes streaming output', async () => { const terminal = { name: 'streaming-terminal' } const manager = createManager() diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index ee86edeba18..0ea98f1db8f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -14,9 +14,9 @@ import { resetTerminalLinkifierHoverState } from '@/lib/pane-manager/terminal-linkifier-hover-reset' import { focusActivePane } from './pane-helpers' -import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' import { flushDeferredPaneMetricOptionsIfMeasurable } from '@/lib/pane-manager/pane-fit' import { repairPaneWebglCanvasDprMismatch } from '@/lib/pane-manager/terminal-canvas-dpr-repair' +import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer' const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024 @@ -88,8 +88,6 @@ export function resumeTerminalVisibility({ // overlay's delayed geometry fit. Still request hidden-output recovery: // agent TUIs can suppress hidden bytes until the pane is foregrounded. requestLightTabBacklogRecovery(manager) - // Why: reveal is the lifecycle boundary that owns hidden renderer repair. - scheduleTabRevealWebglAtlasRecovery() if (flushedDeferredMetrics) { // Why: the light path normally skips fitting, but flushed metrics changed // cell size — refit so cols/rows match before the overlay settles. @@ -109,10 +107,15 @@ export function resumeTerminalVisibility({ // terminals; refresh after reset so rebuilt atlases repaint from xterm. resetAndRefreshAllTerminalWebglAtlases('visibility-resume') } - // Why: the synchronous recovery above can fire before the revealed pane is - // attached and laid out. Follow up after layout with one shared-atlas-safe - // recovery covering every visible terminal manager. - manager.scheduleRevealRepaint() + if (shouldUseLightTabResume) { + // Why: preserve the last coherent frame while a TUI holds DEC 2026. The + // settled refresh arms xterm's watchdog without clearing shared GPU data. + manager.scheduleRevealPresent() + } else { + // Why: rendering was recreated, so the heavy path still needs the proven + // shared-atlas recovery after layout settles. + manager.scheduleRevealRepaint() + } }) } @@ -161,6 +164,13 @@ export function recoverVisibleTerminalWindowWake({ // Why: backlog writes can expose transient viewport geometry while parsing. syncTerminalViewportIntents(manager) for (const pane of manager.getPanes()) { + // Why: clamshell undock / monitor move changes devicePixelRatio while the + // pane can stay "visible" with a stale WebGL backing store. The addon's + // device-pixel observer misses that (no CSS-box change, or no box while + // the lid was closed). Repair here — not only on tab reveal. + if (repairPaneWebglCanvasDprMismatch(pane)) { + presentPaneViewport(pane) + } requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS }) // Why: window blur fires mouseleave, clearing xterm's current link but not @@ -214,6 +224,12 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): // Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume // would paint a stretched DOM-fallback flash, which is worse UX. manager.resumeRendering() + // Why: unchanged grid geometry can skip the reveal fit, but a retained WebGL + // canvas may still carry the previous display's backing-store DPR. The + // caller's atlas recovery presents the final shared-atlas generation. + for (const pane of manager.getPanes()) { + repairPaneWebglCanvasDprMismatch(pane) + } // Why: resumeRendering just re-attached WebGL, whose cell metrics briefly differ // from the DOM renderer's; a raw fit here reflows on a transient one-column-off // grid and garbles diff-painting inline TUIs (grok minimize→restore). diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts index 80013abb860..51be05fc091 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts @@ -5,8 +5,7 @@ import { } from '@/lib/pane-manager/pane-manager-registry' import * as terminalWebglAtlasRecovery from './terminal-webgl-atlas-recovery' -const { scheduleImagePasteWebglAtlasRecovery, scheduleTabRevealWebglAtlasRecovery } = - terminalWebglAtlasRecovery +const { scheduleImagePasteWebglAtlasRecovery } = terminalWebglAtlasRecovery describe('terminal WebGL atlas recovery', () => { const registeredManagers: { resetWebglTextureAtlases(): void }[] = [] @@ -151,26 +150,4 @@ describe('terminal WebGL atlas recovery', () => { expect(healthyManager.refreshAllPanes).toHaveBeenCalledTimes(3) expect(manager.refreshAllPanes).not.toHaveBeenCalled() }) - - it('recovers immediately on a tab reveal', () => { - vi.useFakeTimers() - const rafCallbacks: FrameRequestCallback[] = [] - vi.stubGlobal( - 'requestAnimationFrame', - vi.fn((callback: FrameRequestCallback) => { - rafCallbacks.push(callback) - return rafCallbacks.length - }) - ) - const manager = registerManager() - - scheduleTabRevealWebglAtlasRecovery() - rafCallbacks[0]?.(0) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) - vi.advanceTimersByTime(120) - vi.advanceTimersByTime(380) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3) - }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts index 72dc08e46c1..9253bdd0ffb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts @@ -33,9 +33,3 @@ export function scheduleImagePasteWebglAtlasRecovery(): void { // one-shot event, so recover immediately rather than debouncing. scheduleAtlasRecoveryBurst('image-paste') } - -export function scheduleTabRevealWebglAtlasRecovery(): void { - // Why: a tab reveal is an explicit renderer lifecycle boundary where hidden - // GPU state can be stale; ordinary PTY output must not clear the shared atlas. - scheduleAtlasRecoveryBurst('tab-reveal') -} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts index c7972f542f5..4fdf805100d 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts @@ -260,6 +260,8 @@ describe('useTerminalPaneGlobalEffects', () => { manager.resumeRendering.mockClear() manager.resetWebglTextureAtlases.mockClear() + manager.scheduleRevealRepaint.mockClear() + manager.scheduleRevealPresent.mockClear() manager.refreshAllPanes.mockClear() manager.suspendRendering.mockClear() mocks.fitAndFocusPanes.mockClear() @@ -289,8 +291,10 @@ describe('useTerminalPaneGlobalEffects', () => { expect(manager.resumeRendering).not.toHaveBeenCalled() expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() expect(mocks.fitPanes).not.toHaveBeenCalled() - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(manager.refreshAllPanes).not.toHaveBeenCalled() + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1) expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) vi.advanceTimersByTime(500) }) @@ -342,6 +346,8 @@ describe('useTerminalPaneGlobalEffects', () => { manager.resumeRendering.mockClear() manager.resetWebglTextureAtlases.mockClear() + manager.scheduleRevealRepaint.mockClear() + manager.scheduleRevealPresent.mockClear() manager.refreshAllPanes.mockClear() mocks.fitAndFocusPanes.mockClear() mocks.fitPanes.mockClear() @@ -361,8 +367,10 @@ describe('useTerminalPaneGlobalEffects', () => { expect(manager.resumeRendering).not.toHaveBeenCalled() expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() expect(mocks.fitPanes).not.toHaveBeenCalled() - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(manager.refreshAllPanes).not.toHaveBeenCalled() + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1) expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) vi.advanceTimersByTime(500) }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts index b866c90af42..c903ec1a2f9 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts @@ -2,15 +2,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderHook } from '@testing-library/react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { PaneWebglCanvasDprRepairState } from '@/lib/pane-manager/terminal-canvas-dpr-repair' -const { recoverVisibleTerminalWindowWakeMock } = vi.hoisted(() => ({ - recoverVisibleTerminalWindowWakeMock: vi.fn() +const { + recoverVisibleTerminalWindowWakeMock, + repairPaneWebglCanvasDprMock, + presentPaneViewportMock +} = vi.hoisted(() => ({ + recoverVisibleTerminalWindowWakeMock: vi.fn(), + repairPaneWebglCanvasDprMock: vi.fn<() => PaneWebglCanvasDprRepairState>(() => 'current'), + presentPaneViewportMock: vi.fn() })) vi.mock('./terminal-visibility-resume', () => ({ recoverVisibleTerminalWindowWake: recoverVisibleTerminalWindowWakeMock })) +vi.mock('@/lib/pane-manager/terminal-canvas-dpr-repair', () => ({ + repairPaneWebglCanvasDpr: repairPaneWebglCanvasDprMock +})) + +vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({ + presentPaneViewport: presentPaneViewportMock +})) + import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery' import { getTerminalFreezeBreadcrumbs, @@ -29,6 +44,9 @@ describe('useTerminalWindowWakeRecovery', () => { beforeEach(() => { systemResumedCallback = null recoverVisibleTerminalWindowWakeMock.mockClear() + repairPaneWebglCanvasDprMock.mockClear() + presentPaneViewportMock.mockClear() + repairPaneWebglCanvasDprMock.mockReturnValue('current') unsubscribeSystemResumed.mockClear() onSystemResumed.mockClear() resetTerminalFreezeBreadcrumbsForTesting() @@ -180,4 +198,118 @@ describe('useTerminalWindowWakeRecovery', () => { expect(onSystemResumed).not.toHaveBeenCalled() }) + + it('repairs WebGL canvas dpr on window resize without wiping the glyph atlas', () => { + // Chromium emits resize when devicePixelRatio changes (undock / monitor + // move) even if the CSS box is unchanged. + const pane = { id: 1, terminal: {} } + const resizeManager = { getPanes: () => [pane] } as unknown as PaneManager + repairPaneWebglCanvasDprMock.mockReturnValue('repaired') + vi.stubGlobal('devicePixelRatio', 1) + const { unmount } = renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: resizeManager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).not.toHaveBeenCalled() + + vi.stubGlobal('devicePixelRatio', 2) + window.dispatchEvent(new Event('resize')) + + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledWith(pane) + expect(presentPaneViewportMock).toHaveBeenCalledWith(pane) + expect(recoverVisibleTerminalWindowWakeMock).not.toHaveBeenCalled() + + unmount() + vi.stubGlobal('devicePixelRatio', 3) + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(1) + }) + + it('retains a dpr transition until the visible manager is available', () => { + const pane = { id: 1, terminal: {} } + const managerRef: { current: PaneManager | null } = { current: null } + vi.stubGlobal('devicePixelRatio', 1) + renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + + vi.stubGlobal('devicePixelRatio', 2) + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).not.toHaveBeenCalled() + + managerRef.current = { getPanes: () => [pane] } as unknown as PaneManager + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledWith(pane) + }) + + it('deduplicates a deferred dpr retry and consumes it after repair succeeds', () => { + const pane = { id: 1, terminal: {} } + const callbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callbacks.push(callback) + return callbacks.length + }) + vi.stubGlobal('devicePixelRatio', 1) + repairPaneWebglCanvasDprMock.mockReturnValueOnce('deferred').mockReturnValue('repaired') + renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: { getPanes: () => [pane] } as unknown as PaneManager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + + vi.stubGlobal('devicePixelRatio', 2) + window.dispatchEvent(new Event('resize')) + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(1) + expect(callbacks).toHaveLength(1) + + callbacks.shift()?.(performance.now()) + expect(presentPaneViewportMock).toHaveBeenCalledWith(pane) + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(2) + }) + + it('caps a deferred dpr transition at sixteen animation-frame retries', () => { + const pane = { id: 1, terminal: {} } + const callbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callbacks.push(callback) + return callbacks.length + }) + vi.stubGlobal('devicePixelRatio', 1) + repairPaneWebglCanvasDprMock.mockReturnValue('deferred') + renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: { getPanes: () => [pane] } as unknown as PaneManager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + + vi.stubGlobal('devicePixelRatio', 2) + window.dispatchEvent(new Event('resize')) + let callbackCount = 0 + while (callbacks.length > 0) { + callbacks.shift()?.(performance.now()) + callbackCount += 1 + } + + expect(callbackCount).toBe(16) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(17) + }) }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts index ba8102a5fb1..444d0f1f9dd 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -1,6 +1,8 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume' +import { repairPaneWebglCanvasDpr } from '@/lib/pane-manager/terminal-canvas-dpr-repair' +import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer' import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' import type { IDisposable } from '@xterm/xterm' @@ -16,6 +18,8 @@ type WindowWakePtyBinding = IDisposable & { reassertPtySizeAfterWindowWake?: () => void } +const DPR_RECOVERY_RETRY_FRAMES = 16 + export function useTerminalWindowWakeRecovery({ isVisible, managerRef, @@ -28,7 +32,10 @@ export function useTerminalWindowWakeRecovery({ return } let wakeRecoveryFrameId: number | null = null + let dprRecoveryFrameId: number | null = null + let dprRecoveryFramesRemaining = 0 let settledClearGlyphAtlases = false + let observedDevicePixelRatio = window.devicePixelRatio const cancelScheduledWakeRecovery = (): void => { if (wakeRecoveryFrameId === null || typeof cancelAnimationFrame !== 'function') { wakeRecoveryFrameId = null @@ -37,6 +44,13 @@ export function useTerminalWindowWakeRecovery({ cancelAnimationFrame(wakeRecoveryFrameId) wakeRecoveryFrameId = null } + const cancelScheduledDprRecovery = (): void => { + if (dprRecoveryFrameId !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(dprRecoveryFrameId) + } + dprRecoveryFrameId = null + dprRecoveryFramesRemaining = 0 + } const reassertPanePtySizes = (): void => { for (const binding of panePtyBindingsRef?.current.values() ?? []) { // Why: one settled read avoids duplicate SSH RPCs while still detecting a dropped resize. @@ -111,7 +125,59 @@ export function useTerminalWindowWakeRecovery({ recoverVisibleWake(true, 'system-resumed') } } + const repairVisiblePanesForDpr = (devicePixelRatio: number): boolean => { + const manager = managerRef.current + if (!manager || !isVisibleRef.current) { + return false + } + let deferred = false + for (const pane of manager.getPanes?.() ?? []) { + const state = repairPaneWebglCanvasDpr(pane) + deferred ||= state === 'deferred' + if (state === 'repaired') { + presentPaneViewport(pane) + } + } + if (!deferred) { + observedDevicePixelRatio = devicePixelRatio + } + return !deferred + } + const scheduleDprRecovery = (): void => { + if (dprRecoveryFrameId !== null || typeof requestAnimationFrame !== 'function') { + return + } + dprRecoveryFramesRemaining = DPR_RECOVERY_RETRY_FRAMES + const retry = (): void => { + dprRecoveryFrameId = null + if (repairVisiblePanesForDpr(window.devicePixelRatio)) { + dprRecoveryFramesRemaining = 0 + return + } + dprRecoveryFramesRemaining -= 1 + if (dprRecoveryFramesRemaining > 0) { + dprRecoveryFrameId = requestAnimationFrame(retry) + } + } + dprRecoveryFrameId = requestAnimationFrame(retry) + } + const onWindowResize = (): void => { + // Why: Chromium emits window resize on devicePixelRatio changes even when + // the CSS box is unchanged (monitor move / undock). xterm's own observer + // misses that while the canvas had no box (laptop lid closed). + const devicePixelRatio = window.devicePixelRatio + if (devicePixelRatio === observedDevicePixelRatio) { + return + } + if (dprRecoveryFrameId !== null) { + return + } + if (!repairVisiblePanesForDpr(devicePixelRatio)) { + scheduleDprRecovery() + } + } window.addEventListener('focus', onFocus) + window.addEventListener('resize', onWindowResize) if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { document.addEventListener('visibilitychange', onVisibilityChange) } @@ -125,7 +191,9 @@ export function useTerminalWindowWakeRecovery({ : null return () => { cancelScheduledWakeRecovery() + cancelScheduledDprRecovery() window.removeEventListener('focus', onFocus) + window.removeEventListener('resize', onWindowResize) if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') { document.removeEventListener('visibilitychange', onVisibilityChange) } diff --git a/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts index 10beddbf8b9..9320e0410b4 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts @@ -85,9 +85,43 @@ describe('pane manager registry', () => { expect(order).toEqual(['first-reset', 'second-reset', 'first-refresh', 'second-refresh']) }) + it('clears every recovered atlas before presenting any pane', () => { + // Why: per-pane clear+present interleaves a present against atlas generation + // N with the next pane's wipe to N+1. The first synchronized-output column + // then keeps pre-hide footer pixels. Wipe first, present once the generation + // is final. + const order: string[] = [] + const first = { + resetWebglTextureAtlases: vi.fn<() => void>(() => order.push('first-reset')), + clearWebglTextureAtlases: vi.fn<() => void>(() => order.push('first-clear')), + presentForcedViewports: vi.fn<() => void>(() => order.push('first-present')), + refreshAllPanes: vi.fn<() => void>(() => order.push('first-refresh')), + isVisibleForAtlasRecovery: () => true + } + const second = { + resetWebglTextureAtlases: vi.fn<() => void>(() => order.push('second-reset')), + clearWebglTextureAtlases: vi.fn<() => void>(() => order.push('second-clear')), + presentForcedViewports: vi.fn<() => void>(() => order.push('second-present')), + refreshAllPanes: vi.fn<() => void>(() => order.push('second-refresh')), + isVisibleForAtlasRecovery: () => true + } + registerLivePaneManager(first) + registeredManagers.push(first) + registerLivePaneManager(second) + registeredManagers.push(second) + + resetAndRefreshAllTerminalWebglAtlases() + + expect(order).toEqual(['first-clear', 'second-clear', 'first-present', 'second-present']) + expect(first.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(second.refreshAllPanes).not.toHaveBeenCalled() + }) + it('bounds atlas recovery to visible managers', () => { const visible = { resetWebglTextureAtlases: vi.fn<() => void>(), + clearWebglTextureAtlases: vi.fn<() => void>(), + presentForcedViewports: vi.fn<() => void>(), refreshAllPanes: vi.fn<() => void>(), isVisibleForAtlasRecovery: () => true } @@ -95,6 +129,8 @@ describe('pane manager registry', () => { registeredManagers.push(visible) const hidden = Array.from({ length: 64 }, () => ({ resetWebglTextureAtlases: vi.fn<() => void>(), + clearWebglTextureAtlases: vi.fn<() => void>(), + presentForcedViewports: vi.fn<() => void>(), refreshAllPanes: vi.fn<() => void>(), isVisibleForAtlasRecovery: () => false })) @@ -105,12 +141,14 @@ describe('pane manager registry', () => { resetAndRefreshAllTerminalWebglAtlases() - expect(visible.resetWebglTextureAtlases).toHaveBeenCalledOnce() - expect(visible.refreshAllPanes).toHaveBeenCalledOnce() + expect(visible.clearWebglTextureAtlases).toHaveBeenCalledOnce() + expect(visible.presentForcedViewports).toHaveBeenCalledOnce() expect( - hidden.every((manager) => manager.resetWebglTextureAtlases.mock.calls.length === 0) + hidden.every((manager) => manager.clearWebglTextureAtlases.mock.calls.length === 0) ).toBe(true) - expect(hidden.every((manager) => manager.refreshAllPanes.mock.calls.length === 0)).toBe(true) + expect(hidden.every((manager) => manager.presentForcedViewports.mock.calls.length === 0)).toBe( + true + ) }) it('continues reset-and-refresh recovery when one manager throws', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts index 588970436de..34492cd508e 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts @@ -4,6 +4,8 @@ import type { PaneRenderingDiagnostics } from './pane-manager-types' type RegisteredPaneManager = { resetWebglTextureAtlases(): void + clearWebglTextureAtlases?: () => void + presentForcedViewports?: () => void fitAllPanes?: () => void refreshAllPanes?: () => void getRenderingDiagnostics?: () => PaneRenderingDiagnostics[] @@ -60,9 +62,17 @@ export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void { ...(reason ? { reason } : {}) }) const resetManagers: RegisteredPaneManager[] = [] + // Why: clearTextureAtlas() is module-global. Clearing then presenting per + // pane interleaves a present against generation N with the next pane's wipe + // to N+1, so the first synchronized-output column keeps pre-hide footer pixels. + // Wipe every recovered atlas first; present only once the generation is final. for (const manager of recoveryManagers) { try { - manager.resetWebglTextureAtlases() + if (manager.clearWebglTextureAtlases) { + manager.clearWebglTextureAtlases() + } else { + manager.resetWebglTextureAtlases() + } resetManagers.push(manager) } catch { // Why: recovery is best-effort during pane teardown; a disposed manager @@ -71,7 +81,11 @@ export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void { } for (const manager of resetManagers) { try { - manager.refreshAllPanes?.() + if (manager.presentForcedViewports) { + manager.presentForcedViewports() + } else { + manager.refreshAllPanes?.() + } } catch { // Why: a pane can unmount between atlas reset and repaint; later // managers still need to repaint from their xterm buffers. diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 8a99a00563e..bc05069bdfe 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -26,6 +26,8 @@ import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration' import { rebuildAttachedWebgl } from './pane-webgl-reattach' import { markPaneComplexScriptOutput, + clearPaneWebglTextureAtlases, + presentPaneViewports, resetPaneWebglTextureAtlases, resumePaneRendering, setPaneGpuRenderingState, @@ -280,6 +282,14 @@ export class PaneManager { resetPaneWebglTextureAtlases(this.panes.values()) } + clearWebglTextureAtlases(): void { + clearPaneWebglTextureAtlases(this.panes.values()) + } + + presentForcedViewports(): void { + presentPaneViewports(this.panes.values()) + } + setAtlasRecoveryVisible(visible: boolean): void { this.atlasRecoveryVisible = visible } @@ -296,8 +306,7 @@ export class PaneManager { } scheduleRevealPresent(): void { - // Why: same destroy guard as scheduleRevealRepaint, but presents without - // clearing the shared glyph atlas — used by the plain-refocus recovery. + // Why: ordinary reveal keeps the coherent canvas until DEC 2026 releases. schedulePaneRevealPresent(() => (this.destroyed ? [] : this.panes.values())) } diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts index 2c005f0ff05..1de4621a60f 100644 --- a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts +++ b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts @@ -6,6 +6,8 @@ import { disposeWebgl, isPaneWebglContextLost, markComplexScriptOutput, + clearWebglTextureAtlas, + presentPaneViewport, resetWebglTextureAtlas } from './pane-webgl-renderer' import { rebuildAttachedWebgl, reattachWebglIfNeeded } from './pane-webgl-reattach' @@ -101,3 +103,15 @@ export function resetPaneWebglTextureAtlases(panes: Iterable): void { + for (const pane of panes) { + clearWebglTextureAtlas(pane) + } +} + +export function presentPaneViewports(panes: Iterable): void { + for (const pane of panes) { + presentPaneViewport(pane) + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts index bfc4797aa52..ab2f973bcfa 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts @@ -1,5 +1,6 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { reattachWebglIfNeeded } from './pane-webgl-reattach' +import { presentPaneViewportPreservingSynchronizedOutput } from './pane-webgl-renderer' import { resetAndRefreshAllTerminalWebglAtlases } from './pane-manager-registry' type PaneGetter = () => Iterable @@ -83,21 +84,10 @@ export function schedulePaneRevealRepaint(getPanes: () => Iterable Iterable): void { forEachPaneOnSettledFrame(getPanes, (pane) => { reattachWebglIfNeeded(pane) - if (pane.terminal.rows > 0) { - pane.terminal.refresh(0, pane.terminal.rows - 1) - } + presentPaneViewportPreservingSynchronizedOutput(pane) }) } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts index 11076608027..00c8174967a 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts @@ -4,6 +4,8 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { attachWebgl, clearTerminalWebglAttachBackoff, + presentPaneViewport, + presentPaneViewportPreservingSynchronizedOutput, resetTerminalWebglSuggestion, resetWebglTextureAtlas } from './pane-webgl-renderer' @@ -75,6 +77,7 @@ type FakeRenderService = { function createPausedPane(display: 'block' | 'none'): { pane: ManagedPaneInternal renderService: FakeRenderService + setDisplay: (next: 'block' | 'none') => void } { const pane = createPane() const renderService: FakeRenderService = { @@ -87,7 +90,8 @@ function createPausedPane(display: 'block' | 'none'): { } }) } - const view = { getComputedStyle: () => ({ display }) } + let currentDisplay = display + const view = { getComputedStyle: () => ({ display: currentDisplay }) } const element = { ownerDocument: { defaultView: view }, parentElement: null } pane.container = element as never pane.xtermContainer = element as never @@ -96,7 +100,13 @@ function createPausedPane(display: 'block' | 'none'): { refresh: vi.fn(() => renderService.refreshRows(0, pane.terminal.rows - 1)), _core: { _renderService: renderService } } as never - return { pane, renderService } + return { + pane, + renderService, + setDisplay: (next) => { + currentDisplay = next + } + } } describe('terminal WebGL addon lifecycle', () => { @@ -168,6 +178,58 @@ describe('terminal WebGL addon lifecycle', () => { expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) }) + it('falls back to terminal.refresh on an unpaused, unsynchronized pane', () => { + // Restore-after-replay and other callers use presentPaneViewport even when + // forceFullViewportPresent is a no-op (not paused, no DEC 2026). + const refreshRows = vi.fn() + const renderRows = vi.fn() + const pane = createPane() + pane.terminal = { + ...pane.terminal, + refresh: vi.fn(), + _core: { + _renderService: { + _isPaused: false, + _needsFullRefresh: false, + refreshRows, + _renderer: { value: { renderRows } } + }, + coreService: { decPrivateModes: { synchronizedOutput: false } } + } + } as never + + presentPaneViewport(pane) + + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + expect(refreshRows).not.toHaveBeenCalled() + expect(renderRows).not.toHaveBeenCalled() + }) + + it('preserves a synchronized frame on an ordinary reveal present', () => { + const refreshRows = vi.fn() + const renderRows = vi.fn() + const pane = createPane() + pane.terminal = { + ...pane.terminal, + refresh: vi.fn(), + _core: { + _renderService: { + _isPaused: false, + _needsFullRefresh: false, + refreshRows, + _renderer: { value: { renderRows } } + }, + coreService: { decPrivateModes: { synchronizedOutput: true } } + } + } as never + + presentPaneViewportPreservingSynchronizedOutput(pane) + + expect(refreshRows).toHaveBeenCalledWith(0, 23, true) + expect(renderRows).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + }) + it('keeps the render pause latched when resetting a pane that has no layout box', () => { // Regression: the atlas reset released xterm's pause for every pane of a // visible manager, including a collapsed sibling of an expanded pane. That @@ -185,6 +247,52 @@ describe('terminal WebGL addon lifecycle', () => { expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) }) + it('retries a display:none present after the overlay actually shows a box', () => { + // Field traces show paused=true needFull=true because the + // first present ran while the overlay was still display:none. Selection + // later healed the inner rows; only a resize cleared the sides. Flush the + // retry after the box exists so the full present actually runs. + const queued: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + queued.push(callback) + return queued.length + }) + const { pane, renderService, setDisplay } = createPausedPane('none') + + presentPaneViewport(pane) + + expect(renderService._isPaused).toBe(true) + expect(queued).toHaveLength(1) + + setDisplay('block') + queued.shift()?.(16) + + expect(renderService._isPaused).toBe(false) + expect(renderService.refreshRows).toHaveBeenCalledWith(0, 23, true) + }) + + it('caps display:none present retries', () => { + const queued: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + queued.push(callback) + return queued.length + }) + const { pane, renderService } = createPausedPane('none') + + presentPaneViewport(pane) + presentPaneViewport(pane) + expect(queued).toHaveLength(1) + let callbacks = 0 + while (queued.length > 0) { + queued.shift()?.(callbacks * 16) + callbacks += 1 + } + + expect(callbacks).toBe(16) + expect(renderService._isPaused).toBe(true) + expect(renderService.refreshRows).toHaveBeenCalledTimes(2) + }) + it('still forces the paused render through for a pane that has a layout box', () => { // The reveal case the release exists for: DOM-visible, but xterm's observer // has not caught up, so a plain refresh() would be swallowed. diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index 9d05e6b876b..463bc76e53a 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -1,9 +1,12 @@ import { WebglAddon } from '@xterm/addon-webgl' -import type { ManagedPaneInternal } from './pane-manager-types' +import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' import { getLivePaneCensus } from './pane-manager-registry' import { isManagedPaneDisplayNone } from './pane-display-visibility' -import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' +import { + forceFullViewportPresent, + requestFullViewportPresent +} from './terminal-render-pause-release' import { getTerminalWebglAutoDecision, resetTerminalWebglAutoDecision @@ -138,7 +141,7 @@ export function markComplexScriptOutput(pane: ManagedPaneInternal): void { pane.hasComplexScriptOutput = true } -export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { +export function clearWebglTextureAtlas(pane: ManagedPaneInternal): void { if (pane.webglDisabledAfterContextLoss) { return } @@ -147,22 +150,89 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { // context-loss event. Clearing the atlas preserves GPU rendering and forces // a fresh paint when the pane becomes visible/focused again. pane.webglAddon?.clearTextureAtlas() + } catch { + /* ignore — pane may have been disposed in the meantime */ + } +} + +const DISPLAYED_PRESENT_RETRY_FRAMES = 16 +type ViewportPresentMode = 'preserve-synchronized-output' | 'force-current-buffer' +type DisplayedPresentRetry = { frames: number; mode: ViewportPresentMode } +const pendingDisplayedPresentRetries = new WeakMap() + +function schedulePresentWhenDisplayed(pane: ManagedPaneInternal, mode: ViewportPresentMode): void { + if (typeof globalThis.requestAnimationFrame !== 'function') { + return + } + const pending = pendingDisplayedPresentRetries.get(pane) + if (pending) { + if (mode === 'force-current-buffer') { + pending.mode = mode + } + return + } + pendingDisplayedPresentRetries.set(pane, { + frames: DISPLAYED_PRESENT_RETRY_FRAMES, + mode + }) + const tick = (): void => { + const retry = pendingDisplayedPresentRetries.get(pane) + if (!retry || retry.frames <= 0 || !pane.terminal) { + pendingDisplayedPresentRetries.delete(pane) + return + } + if (isManagedPaneDisplayNone(pane)) { + if (retry.frames === 1) { + pendingDisplayedPresentRetries.delete(pane) + return + } + retry.frames -= 1 + globalThis.requestAnimationFrame(tick) + return + } + pendingDisplayedPresentRetries.delete(pane) + presentPaneViewportWithMode(pane, retry.mode) + } + globalThis.requestAnimationFrame(tick) +} + +function presentPaneViewportWithMode(pane: ManagedPane, mode: ViewportPresentMode): void { + const internal = pane as ManagedPaneInternal + if (internal.webglDisabledAfterContextLoss) { + return + } + try { // Why: on reveal xterm's IntersectionObserver can still report the pane as // not intersecting, so a plain refresh() is swallowed by RenderService's - // paused-render gate and the cleared model never repaints (stale bottom rows - // until a drag-select forces a redraw). Force the paused render through - // first; only fall back to refresh() when the terminal was not gated. + // paused-render gate and the pending model never repaints (stale bottom rows + // until a drag-select forces a redraw). Request one synchronous full present + // even if the observer already unpaused; only fall back to refresh() when + // internals are unavailable. // // Why the display check: that release is only right for a pane that is // DOM-visible. A pane with no box at all (collapsed sibling of an expanded // pane, a restore that stays display:none for its whole reattach) is - // legitimately paused, and releasing it paints the just-cleared model into - // nothing and then leaves the service unpaused for good — the observer only + // legitimately paused, and releasing it paints into nothing and then leaves + // the service unpaused for good — the observer only // fires on a change, so it never re-pauses. Clearing _needsFullRefresh with // it also drops the full repaint the observer owes the pane on reveal, and // the deferred _pausedResizeTask that flushes alongside it. Latching is what // xterm's own gate does, and the reveal repaints from the latch. - if (isManagedPaneDisplayNone(pane) || !forceRepaintThroughRenderPause(pane.terminal)) { + if (isManagedPaneDisplayNone(pane)) { + pane.terminal.refresh(0, pane.terminal.rows - 1) + // Why: light tab reveal runs while the overlay is still display:none + // (field trace: paused=true needFull=true at click). A plain refresh only + // latches _needsFullRefresh; if IntersectionObserver never fires, the + // canvas keeps pre-hide pixels until a user resize. Retry once the box + // exists so the full present actually runs. + schedulePresentWhenDisplayed(internal, mode) + return + } + const presented = + mode === 'force-current-buffer' + ? forceFullViewportPresent(pane.terminal) + : requestFullViewportPresent(pane.terminal) + if (!presented) { // Why: refresh even without a WebGL addon so recovery never silently // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. pane.terminal.refresh(0, pane.terminal.rows - 1) @@ -172,6 +242,19 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { } } +export function presentPaneViewport(pane: ManagedPane): void { + presentPaneViewportWithMode(pane, 'force-current-buffer') +} + +export function presentPaneViewportPreservingSynchronizedOutput(pane: ManagedPane): void { + presentPaneViewportWithMode(pane, 'preserve-synchronized-output') +} + +export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { + clearWebglTextureAtlas(pane) + presentPaneViewport(pane) +} + function refitAfterFitAnchoredWebglAttach(pane: ManagedPaneInternal): void { // Why: the fit that triggered this attach measured DOM cell metrics, but WebGL // floors the device cell width — keeping that grid leaves an unpainted right diff --git a/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts b/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts index 188b8441104..0d7f241ffbf 100644 --- a/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it, vi } from 'vitest' import type { ManagedPane } from './pane-manager-types' -import { repairPaneWebglCanvasDprMismatch } from './terminal-canvas-dpr-repair' +import { + repairPaneWebglCanvasDpr, + repairPaneWebglCanvasDprMismatch +} from './terminal-canvas-dpr-repair' function makePane(args: { backingWidth: number expectedWidth: number dpr: number + cachedDpr?: number backingHeight?: number expectedHeight?: number connected?: boolean @@ -32,6 +36,7 @@ function makePane(args: { (args.hasRenderer ?? true) ? { _canvas: canvas, + _devicePixelRatio: args.cachedDpr ?? args.dpr, dimensions: { device: { canvas: { width: args.expectedWidth, height: args.expectedHeight ?? 1200 } @@ -79,6 +84,19 @@ describe('repairPaneWebglCanvasDprMismatch', () => { expect(handleResize).toHaveBeenCalledTimes(1) }) + it('repairs when the canvas and renderer dimensions share a stale dpr cache', () => { + const { pane, handleDevicePixelRatioChange, handleResize } = makePane({ + backingWidth: 1080, + expectedWidth: 1080, + dpr: 2, + cachedDpr: 1 + }) + + expect(repairPaneWebglCanvasDprMismatch(pane)).toBe(true) + expect(handleDevicePixelRatioChange).toHaveBeenCalledTimes(1) + expect(handleResize).toHaveBeenCalledWith(120, 40) + }) + it('is a no-op when backing matches the renderer device dimensions', () => { const { pane, handleResize, refresh } = makePane({ backingWidth: 2160, @@ -136,6 +154,24 @@ describe('repairPaneWebglCanvasDprMismatch', () => { expect(repairPaneWebglCanvasDprMismatch(noRenderer.pane)).toBe(false) }) + it('defers an unmeasurable WebGL canvas but accepts a renderer-less pane', () => { + const detached = makePane({ + backingWidth: 2160, + expectedWidth: 1080, + dpr: 1, + connected: false + }) + const noRenderer = makePane({ + backingWidth: 2160, + expectedWidth: 1080, + dpr: 1, + hasRenderer: false + }) + + expect(repairPaneWebglCanvasDpr(detached.pane)).toBe('deferred') + expect(repairPaneWebglCanvasDpr(noRenderer.pane)).toBe('current') + }) + it('reports failure without throwing when the repair path throws mid-teardown', () => { const { pane, handleResize } = makePane({ backingWidth: 2160, diff --git a/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts b/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts index 72bde40b30b..587e01288e4 100644 --- a/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts +++ b/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts @@ -14,6 +14,7 @@ import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl */ type XtermRendererInternals = { _canvas?: HTMLCanvasElement + _devicePixelRatio?: number _gl?: { canvas?: HTMLCanvasElement } dimensions?: { device?: { canvas?: { width?: number; height?: number } } @@ -22,33 +23,42 @@ type XtermRendererInternals = { handleResize?: (cols: number, rows: number) => void } -export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean { +export type PaneWebglCanvasDprRepairState = 'current' | 'deferred' | 'repaired' + +export function repairPaneWebglCanvasDpr(pane: ManagedPane): PaneWebglCanvasDprRepairState { const renderer = ( pane.terminal as unknown as { _core?: { _renderService?: { _renderer?: { value?: XtermRendererInternals } } } } )._core?._renderService?._renderer?.value const canvas = renderer?._canvas ?? renderer?._gl?.canvas - if (!renderer || !canvas?.isConnected) { - return false + if (!renderer || !canvas) { + return 'current' + } + if (!canvas.isConnected) { + return 'deferred' } const view = canvas.ownerDocument?.defaultView const expected = renderer.dimensions?.device?.canvas const expectedWidth = expected?.width ?? 0 const expectedHeight = expected?.height ?? 0 if (!view || expectedWidth <= 0 || expectedHeight <= 0) { - return false + return 'deferred' } const staleBackingWidth = canvas.width const staleBackingHeight = canvas.height + const cachedDevicePixelRatio = renderer._devicePixelRatio + const devicePixelRatioChanged = + typeof cachedDevicePixelRatio === 'number' && cachedDevicePixelRatio !== view.devicePixelRatio // xterm rounds its CSS canvas size before ResizeObserver converts it back to // device pixels; allow that round trip without forcing layout on every fit. const roundingTolerance = Math.max(1, Math.ceil(view.devicePixelRatio / 2)) if ( + !devicePixelRatioChanged && Math.abs(staleBackingWidth - expectedWidth) <= roundingTolerance && Math.abs(staleBackingHeight - expectedHeight) <= roundingTolerance ) { - return false + return 'current' } try { // Order matters: refresh the renderer's cached dpr/dimensions first, then @@ -58,13 +68,18 @@ export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean { pane.terminal.refresh(0, pane.terminal.rows - 1) } catch { // Pane may be mid-teardown; the next reveal/fit retries the check. - return false + return 'deferred' } recordTerminalWebglDiagnostic('webgl-canvas-dpr-repair', { paneId: pane.id, staleBackingWidth, expectedBackingWidth: expectedWidth, + ...(cachedDevicePixelRatio === undefined ? {} : { cachedDevicePixelRatio }), devicePixelRatio: view.devicePixelRatio }) - return true + return 'repaired' +} + +export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean { + return repairPaneWebglCanvasDpr(pane) === 'repaired' } diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts index e3d84677424..a0a81ec16d1 100644 --- a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts @@ -1,24 +1,35 @@ import { describe, expect, it, vi } from 'vitest' -import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' +import { + forceFullViewportPresent, + forceRepaintThroughRenderPause, + requestFullViewportPresent +} from './terminal-render-pause-release' type FakeRenderService = { _isPaused?: boolean _needsFullRefresh?: boolean refreshRows?: ReturnType + _renderer?: { + value?: { clear?: ReturnType; renderRows?: ReturnType } + } } function createTerminal(options: { rows?: number renderService?: FakeRenderService | null withoutCore?: boolean + synchronizedOutput?: boolean }): unknown { - const { rows = 24, renderService, withoutCore } = options + const { rows = 24, renderService, withoutCore, synchronizedOutput } = options if (withoutCore) { return { rows } } return { rows, - _core: { _renderService: renderService ?? null } + _core: { + _renderService: renderService ?? null, + coreService: { decPrivateModes: { synchronizedOutput: synchronizedOutput === true } } + } } } @@ -85,3 +96,131 @@ describe('forceRepaintThroughRenderPause', () => { expect(renderService._isPaused).toBe(false) }) }) + +describe('forceFullViewportPresent', () => { + it('fails closed when xterm internals are unavailable', () => { + expect(forceFullViewportPresent(null)).toBe(false) + }) + + it('leaves an unpaused, unsynchronized terminal to the normal refresh path', () => { + // A forced sync renderRows on first splash paints before cell metrics + // settle and shows a 1px black gutter under the TUI composer. + const refreshRows = vi.fn() + const renderRows = vi.fn() + const terminal = createTerminal({ + rows: 24, + renderService: { + _isPaused: false, + _needsFullRefresh: false, + refreshRows, + _renderer: { value: { renderRows } } + } + }) + + expect(forceFullViewportPresent(terminal)).toBe(false) + expect(renderRows).not.toHaveBeenCalled() + expect(refreshRows).not.toHaveBeenCalled() + }) + + it('paints through the renderer so DEC 2026 cannot swallow the reveal present', () => { + const refreshRows = vi.fn() + const renderRows = vi.fn() + const renderService = { + _isPaused: false, + _needsFullRefresh: false, + refreshRows, + _renderer: { value: { renderRows } } + } + const terminal = createTerminal({ + rows: 24, + renderService, + synchronizedOutput: true + }) + + expect(forceFullViewportPresent(terminal)).toBe(true) + expect(renderRows).toHaveBeenCalledWith(0, 23) + expect(refreshRows).not.toHaveBeenCalled() + }) + + it('uses RenderService refreshRows when paused without DEC 2026, matching production splash', () => { + const refreshRows = vi.fn() + const renderRows = vi.fn() + const renderService = { + _isPaused: true, + _needsFullRefresh: true, + refreshRows, + _renderer: { value: { renderRows } } + } + const terminal = createTerminal({ rows: 24, renderService }) + + expect(forceFullViewportPresent(terminal)).toBe(true) + expect(refreshRows).toHaveBeenCalledWith(0, 23, true) + expect(renderRows).not.toHaveBeenCalled() + expect(renderService._isPaused).toBe(false) + }) + + it('leaves pause cleared when the forced render throws so callers can refresh()', () => { + const renderService = { + _isPaused: true, + _needsFullRefresh: true, + refreshRows: vi.fn(() => { + throw new Error('terminal disposed') + }), + _renderer: { value: { renderRows: vi.fn() } } + } + const terminal = createTerminal({ rows: 24, renderService }) + + expect(forceFullViewportPresent(terminal)).toBe(false) + expect(renderService._isPaused).toBe(false) + expect(renderService._needsFullRefresh).toBe(false) + }) +}) + +describe('requestFullViewportPresent', () => { + it('fails closed when xterm internals are unavailable', () => { + expect(requestFullViewportPresent(null)).toBe(false) + }) + + it('leaves a normal visible terminal on the debounced refresh path', () => { + const refreshRows = vi.fn() + const terminal = createTerminal({ + renderService: { _isPaused: false, refreshRows } + }) + + expect(requestFullViewportPresent(terminal)).toBe(false) + expect(refreshRows).not.toHaveBeenCalled() + }) + + it('routes synchronized output through RenderService instead of the renderer', () => { + const refreshRows = vi.fn() + const renderRows = vi.fn() + const terminal = createTerminal({ + rows: 24, + synchronizedOutput: true, + renderService: { + _isPaused: false, + refreshRows, + _renderer: { value: { renderRows } } + } + }) + + expect(requestFullViewportPresent(terminal)).toBe(true) + expect(refreshRows).toHaveBeenCalledWith(0, 23, true) + expect(renderRows).not.toHaveBeenCalled() + }) + + it('releases observer pause before requesting the synchronized frame', () => { + const refreshRows = vi.fn() + const renderService = { + _isPaused: true, + _needsFullRefresh: true, + refreshRows + } + const terminal = createTerminal({ rows: 30, renderService, synchronizedOutput: true }) + + expect(requestFullViewportPresent(terminal)).toBe(true) + expect(renderService._isPaused).toBe(false) + expect(renderService._needsFullRefresh).toBe(false) + expect(refreshRows).toHaveBeenCalledWith(0, 29, true) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts index 88b53129d87..552939e6cd8 100644 --- a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts @@ -17,10 +17,15 @@ * never throws into a render frame. */ +type MaybeWebglRenderer = { + renderRows?: (start: number, end: number) => void +} + type MaybePausableRenderService = { _isPaused?: boolean _needsFullRefresh?: boolean refreshRows?: (start: number, end: number, sync?: boolean) => void + _renderer?: { value?: MaybeWebglRenderer | null } | MaybeWebglRenderer | null } type PausableRenderService = MaybePausableRenderService & { @@ -31,6 +36,8 @@ type TerminalWithRenderService = { rows?: number _core?: { _renderService?: MaybePausableRenderService + coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } + _coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } } } @@ -71,3 +78,108 @@ export function forceRepaintThroughRenderPause(terminal: unknown): boolean { return false } } + +/** + * Requests a full viewport while preserving a TUI's synchronized-output frame. + * + * Why: ordinary reveal must not publish a half-built DEC 2026 frame. Routing + * through RenderService keeps the previous canvas coherent and arms xterm's + * bounded safety timeout if the TUI never closes the frame. + */ +export function requestFullViewportPresent(terminal: unknown): boolean { + const service = getRenderService(terminal) + if (!service) { + return false + } + const rows = (terminal as TerminalWithRenderService).rows + if (typeof rows !== 'number' || rows < 1) { + return false + } + + const paused = service._isPaused === true + if (!paused && !isSynchronizedOutputHeld(terminal)) { + return false + } + + if (paused) { + service._isPaused = false + service._needsFullRefresh = false + } + + try { + service.refreshRows(0, rows - 1, true) + return true + } catch { + return false + } +} + +function getRenderer(service: MaybePausableRenderService): MaybeWebglRenderer | null { + const holder = service._renderer + if (!holder) { + return null + } + if (typeof (holder as MaybeWebglRenderer).renderRows === 'function') { + return holder as MaybeWebglRenderer + } + const wrapped = (holder as { value?: MaybeWebglRenderer | null }).value + return wrapped ?? null +} + +function isSynchronizedOutputHeld(terminal: unknown): boolean { + const core = (terminal as TerminalWithRenderService)._core + return ( + (core?.coreService?.decPrivateModes ?? core?._coreService?.decPrivateModes) + ?.synchronizedOutput === true + ) +} + +/** + * One synchronous full-viewport present when xterm would otherwise swallow it: + * IntersectionObserver pause, or DEC 2026 synchronized output. + * + * Why not on every paint: a forced renderer.renderRows on a fresh, unpaused + * splash paints before cell metrics settle and leaves a 1px black gutter under + * a TUI composer — production never does that. Callers fall back to + * terminal.refresh() for the normal path. + */ +export function forceFullViewportPresent(terminal: unknown): boolean { + const service = getRenderService(terminal) + if (!service) { + return false + } + const rows = (terminal as TerminalWithRenderService).rows + if (typeof rows !== 'number' || rows < 1) { + return false + } + + const paused = service._isPaused === true + const syncHeld = isSynchronizedOutputHeld(terminal) + if (!paused && !syncHeld) { + return false + } + + if (paused) { + service._isPaused = false + service._needsFullRefresh = false + } + + const renderer = getRenderer(service) + try { + // Why: a new TUI tab is often still paused (observer lag). Painting + // via renderer.renderRows skips RenderService's dimension clamp and draws + // 1px short of the composer box. Production uses refreshRows here. + // renderer.renderRows is only for DEC 2026, which swallows refreshRows. + if (syncHeld && typeof renderer?.renderRows === 'function') { + renderer.renderRows(0, rows - 1) + return true + } + service.refreshRows(0, rows - 1, true) + return true + } catch { + // Why: same as forceRepaintThroughRenderPause — leave the latch cleared so + // the caller's terminal.refresh() fallback can still paint. Restoring + // _isPaused would swallow that refresh until IntersectionObserver fires. + return false + } +} diff --git a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts index 5b280037da4..9efabdea164 100644 --- a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts +++ b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts @@ -1,6 +1,7 @@ +import { writeFile } from 'node:fs/promises' import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' -import { getActiveTabId, waitForSessionReady } from './helpers/store' +import { getActiveTabId, getActiveWorktreeId, waitForSessionReady } from './helpers/store' import { execInTerminal, waitForActivePanePtyId, @@ -19,7 +20,7 @@ import { compareTerminalScreenshots } from './terminal-screenshot-diff' * is then swallowed and the freshly-cleared render model never repaints. * * This spec drives the REAL production reveal path (manager.resetWebglTextureAtlases - * -> resetWebglTextureAtlas -> forceRepaintThroughRenderPause) against a real + * -> resetWebglTextureAtlas -> forceFullViewportPresent) against a real * xterm Terminal + RenderService. It: * 1. proves the bug: while paused, a plain refresh() renders nothing; * 2. proves the fix: the real reveal repaint forces a full-viewport render @@ -50,6 +51,16 @@ type RevealRenderDebug = { type RevealProbeWindow = Window & { __revealRenderProbe?: RevealRenderDebug + __syncRevealProbe?: { + paintFrame: (marker: string, background: number, release: boolean) => Promise + forceRendererPresent: () => void + read: () => { + atlasClears: number + fullViewportRenderRows: number + screen: string + synchronizedOutput: boolean + } + } } /** @@ -182,6 +193,154 @@ async function forceWebglOn(page: Page, tabId: string): Promise { }, tabId) } +async function installSynchronizedRevealProbe(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = [ + ...((manager as unknown as { panes?: Map } | undefined)?.panes?.values() ?? + []) + ][0] as + | { + serializeAddon?: { serialize?: () => string } + terminal: unknown + webglAddon?: unknown + } + | undefined + const addon = pane?.webglAddon as { clearTextureAtlas: () => void } | null | undefined + type SyncRenderService = { + _renderer?: + | { renderRows?: (start: number, end: number) => void } + | { value?: { renderRows?: (start: number, end: number) => void } | null } + _syncOutputHandler?: { _timeout?: number } + } + const terminal = pane?.terminal as + | { + cols: number + rows: number + write: (data: string, callback: () => void) => void + _core?: { + coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } + _coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } + _renderService?: SyncRenderService + } + } + | undefined + const service = terminal?._core?._renderService + const modes = + terminal?._core?.coreService?.decPrivateModes ?? + terminal?._core?._coreService?.decPrivateModes + const rendererHolder = service?._renderer + const renderer = + rendererHolder && 'renderRows' in rendererHolder + ? rendererHolder + : rendererHolder && 'value' in rendererHolder + ? rendererHolder.value + : null + if (!pane || !addon || !terminal || !service || !modes || !renderer?.renderRows) { + throw new Error( + JSON.stringify({ + addon: Boolean(addon), + modes: Boolean(modes), + pane: Boolean(pane), + renderer: Boolean(renderer?.renderRows), + rendererKeys: rendererHolder ? Object.keys(rendererHolder) : [], + service: Boolean(service), + terminal: Boolean(terminal) + }) + ) + } + + let atlasClears = 0 + let fullViewportRenderRows = 0 + const originalClear = addon.clearTextureAtlas.bind(addon) + addon.clearTextureAtlas = () => { + atlasClears += 1 + originalClear() + } + const originalRenderRows = renderer.renderRows.bind(renderer) + renderer.renderRows = (start: number, end: number) => { + if (start === 0 && end >= terminal.rows - 1) { + fullViewportRenderRows += 1 + } + originalRenderRows(start, end) + } + + const paintFrame = (marker: string, background: number, release: boolean): Promise => { + const width = Math.max(1, terminal.cols) + const rows = Math.max(1, terminal.rows) + const line = ` ${marker} `.padEnd(width, marker[0] ?? '#').slice(0, width) + const frame = Array.from( + { length: rows }, + (_, row) => `\x1b[${row + 1};1H\x1b[48;5;${background}m\x1b[38;5;231m${line}` + ).join('') + return new Promise((resolve) => { + terminal.write( + `\x1b[?2026h\x1b[?1049h\x1b[2J${frame}\x1b[0m${release ? '\x1b[?2026l' : ''}`, + () => { + if (!release && service._syncOutputHandler?._timeout !== undefined) { + window.clearTimeout(service._syncOutputHandler._timeout) + service._syncOutputHandler._timeout = undefined + } + resolve() + } + ) + }) + } + + ;(window as RevealProbeWindow).__syncRevealProbe = { + paintFrame, + forceRendererPresent: () => renderer.renderRows?.(0, terminal.rows - 1), + read: () => ({ + atlasClears, + fullViewportRenderRows, + screen: pane.serializeAddon?.serialize?.() ?? '', + synchronizedOutput: modes.synchronizedOutput === true + }) + } + return true + }, tabId) +} + +async function callSynchronizedRevealProbe( + page: Page, + action: 'forceRendererPresent' | 'paintFrame', + frame?: { marker: string; background: number; release: boolean } +): Promise { + await page.evaluate( + async ({ action, frame }) => { + const probe = (window as RevealProbeWindow).__syncRevealProbe + if (!probe) { + throw new Error('Synchronized reveal probe not installed') + } + if (action === 'paintFrame') { + if (!frame) { + throw new Error('Synchronized frame missing') + } + await probe.paintFrame(frame.marker, frame.background, frame.release) + return + } + probe.forceRendererPresent() + }, + { action, frame } + ) +} + +async function readSynchronizedRevealProbe(page: Page) { + return page.evaluate(() => { + const probe = (window as RevealProbeWindow).__syncRevealProbe + if (!probe) { + throw new Error('Synchronized reveal probe not installed') + } + return probe.read() + }) +} + +async function captureFirstRevealedFrame(page: Page, tabId: string): Promise { + const screen = page.locator(`[data-terminal-tab-id="${tabId}"] .xterm-screen`).first() + await expect(screen).toBeVisible() + return screen.screenshot({ animations: 'disabled' }) +} + test.describe('terminal reveal paused-render recovery', () => { test("reveal repaint forces a render through xterm's paused gate", async ({ orcaPage }) => { // Why: __store / __paneManagers live on the main Orca renderer window @@ -265,4 +424,233 @@ test.describe('terminal reveal paused-render recovery', () => { `recovered surface matches the revealed content (diffRatio=${diff.diffRatio})` ).toBe(true) }) + + test('@headful atlas recovery presents a synchronized-output WebGL frame', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const tabId = (await getActiveTabId(orcaPage))! + await forceWebglOn(orcaPage, tabId) + const webglAttached = await orcaPage + .waitForFunction( + (tabId) => + (window.__paneManagers?.get(tabId)?.getRenderingDiagnostics?.() ?? []).some( + (diagnostic) => diagnostic.hasWebgl + ), + tabId, + { timeout: 15_000 } + ) + .then(() => true) + .catch(() => false) + test.skip(!webglAttached, 'WebGL renderer is unavailable') + if (!webglAttached) { + return + } + const installed = await installSynchronizedRevealProbe(orcaPage, tabId) + expect(installed, 'WebGL renderer internals are available').toBe(true) + + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'BASELINE_FRAME', + background: 17, + release: true + }) + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'REVEALED_FRAME', + background: 52, + release: false + }) + const held = await readSynchronizedRevealProbe(orcaPage) + expect(held.synchronizedOutput).toBe(true) + expect(held.screen).toContain('REVEALED_FRAME') + + await orcaPage.evaluate((tabId) => { + window.__paneManagers?.get(tabId)?.scheduleRevealRepaint?.() + }, tabId) + await expect + .poll(async () => (await readSynchronizedRevealProbe(orcaPage)).atlasClears) + .toBeGreaterThan(0) + const afterReveal = await captureStableTabScreenshot(orcaPage, tabId) + + await callSynchronizedRevealProbe(orcaPage, 'forceRendererPresent') + const afterForcedPresent = await captureStableTabScreenshot(orcaPage, tabId) + const diff = compareTerminalScreenshots(afterReveal, afterForcedPresent) + const afterRevealPath = testInfo.outputPath('synchronized-frame-after-reveal.png') + const afterForcedPresentPath = testInfo.outputPath( + 'synchronized-frame-after-forced-present.png' + ) + const pixelDiffPath = testInfo.outputPath('synchronized-frame-pixel-diff.json') + await Promise.all([ + writeFile(afterRevealPath, afterReveal), + writeFile(afterForcedPresentPath, afterForcedPresent), + writeFile(pixelDiffPath, JSON.stringify(diff, null, 2)) + ]) + await testInfo.attach('synchronized-frame-after-reveal.png', { + path: afterRevealPath, + contentType: 'image/png' + }) + await testInfo.attach('synchronized-frame-after-forced-present.png', { + path: afterForcedPresentPath, + contentType: 'image/png' + }) + await testInfo.attach('synchronized-frame-pixel-diff.json', { + path: pixelDiffPath, + contentType: 'application/json' + }) + expect( + diff.matches, + `revealed pixels already match a forced buffer present (diffRatio=${diff.diffRatio})` + ).toBe(true) + }) + + test('@headful reveal preserves the coherent frame until synchronized output releases', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const tabId = (await getActiveTabId(orcaPage))! + const worktreeId = (await getActiveWorktreeId(orcaPage))! + await forceWebglOn(orcaPage, tabId) + const webglAttached = await orcaPage + .waitForFunction( + (tabId) => + (window.__paneManagers?.get(tabId)?.getRenderingDiagnostics?.() ?? []).some( + (diagnostic) => diagnostic.hasWebgl + ), + tabId, + { timeout: 15_000 } + ) + .then(() => true) + .catch(() => false) + test.skip(!webglAttached, 'WebGL renderer is unavailable') + if (!webglAttached) { + return + } + expect(await installSynchronizedRevealProbe(orcaPage, tabId)).toBe(true) + + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'COHERENT_FRAME', + background: 17, + release: true + }) + const coherent = await captureStableTabScreenshot(orcaPage, tabId) + const beforeHide = await readSynchronizedRevealProbe(orcaPage) + const siblingTabId = await orcaPage.evaluate((worktreeId) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Renderer store unavailable') + } + return state.createTab(worktreeId, undefined, undefined, { activate: false }).id + }, worktreeId) + await orcaPage.evaluate( + (siblingTabId) => window.__store?.getState().setActiveTab(siblingTabId), + siblingTabId + ) + await expect(orcaPage.locator(`[data-terminal-tab-id="${tabId}"]`)).toBeHidden() + + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'PENDING_FRAME', + background: 52, + release: false + }) + await orcaPage.evaluate((tabId) => window.__store?.getState().setActiveTab(tabId), tabId) + await expect.poll(() => getActiveTabId(orcaPage)).toBe(tabId) + const held = await captureFirstRevealedFrame(orcaPage, tabId) + const heldState = await readSynchronizedRevealProbe(orcaPage) + const heldDiff = compareTerminalScreenshots(coherent, held) + + expect(heldState.synchronizedOutput).toBe(true) + expect(heldState.screen).toContain('PENDING_FRAME') + expect(heldState.atlasClears - beforeHide.atlasClears, 'ordinary reveal atlas clears').toBe(0) + expect( + heldDiff.matches, + `held reveal preserves the coherent frame (diffRatio=${heldDiff.diffRatio})` + ).toBe(true) + + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'PENDING_FRAME', + background: 52, + release: true + }) + const released = await captureStableTabScreenshot(orcaPage, tabId) + const releaseState = await readSynchronizedRevealProbe(orcaPage) + await callSynchronizedRevealProbe(orcaPage, 'forceRendererPresent') + const direct = await captureStableTabScreenshot(orcaPage, tabId) + const releasedDiff = compareTerminalScreenshots(released, direct) + + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'WATCHDOG_FRAME', + background: 88, + release: false + }) + const beforeWatchdog = await readSynchronizedRevealProbe(orcaPage) + await orcaPage.evaluate((tabId) => { + window.__paneManagers?.get(tabId)?.scheduleRevealPresent?.() + }, tabId) + await expect + .poll(async () => (await readSynchronizedRevealProbe(orcaPage)).synchronizedOutput, { + timeout: 2_500 + }) + .toBe(false) + const watchdog = await captureStableTabScreenshot(orcaPage, tabId) + const watchdogState = await readSynchronizedRevealProbe(orcaPage) + await callSynchronizedRevealProbe(orcaPage, 'forceRendererPresent') + const watchdogDirect = await captureStableTabScreenshot(orcaPage, tabId) + const watchdogDiff = compareTerminalScreenshots(watchdog, watchdogDirect) + const coherentPath = testInfo.outputPath('synchronized-coherent-before-hide.png') + const heldPath = testInfo.outputPath('synchronized-coherent-while-held.png') + const releasedPath = testInfo.outputPath('synchronized-new-frame-after-release.png') + const watchdogPath = testInfo.outputPath('synchronized-new-frame-after-watchdog.png') + const metricsPath = testInfo.outputPath('synchronized-prevention-metrics.json') + const counts = (state: Awaited>) => ({ + atlasClears: state.atlasClears, + fullViewportRenderRows: state.fullViewportRenderRows, + synchronizedOutput: state.synchronizedOutput + }) + await Promise.all([ + writeFile(coherentPath, coherent), + writeFile(heldPath, held), + writeFile(releasedPath, released), + writeFile(watchdogPath, watchdog), + writeFile( + metricsPath, + JSON.stringify( + { + beforeHide: counts(beforeHide), + heldState: counts(heldState), + releaseState: counts(releaseState), + beforeWatchdog: counts(beforeWatchdog), + watchdogState: counts(watchdogState), + heldDiff, + releasedDiff, + watchdogDiff + }, + null, + 2 + ) + ) + ]) + for (const [name, path] of [ + ['synchronized-coherent-before-hide.png', coherentPath], + ['synchronized-coherent-while-held.png', heldPath], + ['synchronized-new-frame-after-release.png', releasedPath], + ['synchronized-new-frame-after-watchdog.png', watchdogPath], + ['synchronized-prevention-metrics.json', metricsPath] + ] as const) { + await testInfo.attach(name, { + path, + contentType: name.endsWith('.json') ? 'application/json' : 'image/png' + }) + } + expect(releaseState.synchronizedOutput).toBe(false) + expect( + releasedDiff.matches, + `released frame matches a direct renderer present (diffRatio=${releasedDiff.diffRatio})` + ).toBe(true) + expect(watchdogState.atlasClears - beforeWatchdog.atlasClears).toBe(0) + expect( + watchdogDiff.matches, + `watchdog frame matches a direct renderer present (diffRatio=${watchdogDiff.diffRatio})` + ).toBe(true) + }) }) From f5fd7303ab00bcfeff72c92f2bc33ba9364cd622 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:58:19 -0700 Subject: [PATCH 04/12] test(e2e): cover tab-bar agent launches on Windows and WSL (#16110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(e2e): gate the tab-bar agent launcher on Windows shells and WSL The `+` menu agent launcher had no golden coverage in the Windows lane, so a Windows-only break anywhere in its chain (detection row, startup-plan build, tab create, PTY spawn, startup-command injection) could ship unnoticed. Adds a golden spec that launches a stub agent from the menu and asserts the agent's own banner reached the pane — a tab that spawned a bare shell instead is indistinguishable at the store/tab layer. Runs two agents everywhere, and on Windows also PowerShell, cmd, Git Bash and a WSL project runtime. * test(e2e): track WSL stub agent staging state for precise cleanup Refactor `stageWslGoldenStubAgent` to track which artifacts it creates during setup, then only remove those artifacts during cleanup. This prevents the test from destructively removing pre-existing symlinks or state from previous runs, improving test isolation and idempotency. * test(e2e): track WSL stub agent staging state for precise cleanup - Back up and restore pre-existing stub agents to avoid destroying them - Simplify verbose test comments to match project style guidelines * test(e2e): serialize WSL stub agent setup with distributed lock - Add mkdir-based lock to prevent concurrent staging invocations - Reclaim stale locks after 10 minutes to recover from crashes - Track lock ownership in stage state for safe cleanup * test(e2e): track WSL stub agent staging state for precise cleanup Track which stubs this test helper stages by writing a marker file, then only remove stubs during stale-lock recovery if we created them. Prevents cleanup from removing stubs left by other processes. --- .github/workflows/golden-e2e-experiment.yml | 3 + package.json | 1 + tests/e2e/fixtures/golden-stub-agent/claude | 2 + .../e2e/fixtures/golden-stub-agent/claude.cmd | 2 + tests/e2e/golden-tab-bar-agent-launch.spec.ts | 90 ++++++++++ tests/e2e/helpers/golden-stub-agent.ts | 50 ++++-- tests/e2e/helpers/wsl-golden-stub-agent.ts | 159 ++++++++++++++++++ 7 files changed, 293 insertions(+), 14 deletions(-) create mode 100755 tests/e2e/fixtures/golden-stub-agent/claude create mode 100644 tests/e2e/fixtures/golden-stub-agent/claude.cmd create mode 100644 tests/e2e/golden-tab-bar-agent-launch.spec.ts create mode 100644 tests/e2e/helpers/wsl-golden-stub-agent.ts diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 1f0a2b42755..040996489d1 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -74,6 +74,7 @@ jobs: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:tab-bar-agent-launch-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden - name: Run golden E2E tests on macOS @@ -87,6 +88,7 @@ jobs: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:tab-bar-agent-launch-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden - name: Run golden E2E tests on Windows @@ -97,6 +99,7 @@ jobs: $env:ORCA_E2E_FORWARD_APP_LOGS = '1' pnpm run --if-present test:e2e:workspace-session-golden pnpm run --if-present test:e2e:windows-fresh-startup-golden + pnpm run --if-present test:e2e:tab-bar-agent-launch-golden if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) { pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts } diff --git a/package.json b/package.json index a026bdb7824..66d0d0bf53e 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:agent-tui-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-agent-tui-launch.spec.ts tests/e2e/golden-shell-after-agent-exit.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "test:e2e:tab-bar-agent-launch-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-tab-bar-agent-launch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf:scale": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-e2e.mjs", diff --git a/tests/e2e/fixtures/golden-stub-agent/claude b/tests/e2e/fixtures/golden-stub-agent/claude new file mode 100755 index 00000000000..d16a99de266 --- /dev/null +++ b/tests/e2e/fixtures/golden-stub-agent/claude @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$(dirname "$0")/golden-stub-agent" "$@" diff --git a/tests/e2e/fixtures/golden-stub-agent/claude.cmd b/tests/e2e/fixtures/golden-stub-agent/claude.cmd new file mode 100644 index 00000000000..894a9216aa2 --- /dev/null +++ b/tests/e2e/fixtures/golden-stub-agent/claude.cmd @@ -0,0 +1,2 @@ +@echo off +call "%~dp0golden-stub-agent.cmd" %* diff --git a/tests/e2e/golden-tab-bar-agent-launch.spec.ts b/tests/e2e/golden-tab-bar-agent-launch.spec.ts new file mode 100644 index 00000000000..d8501c0a668 --- /dev/null +++ b/tests/e2e/golden-tab-bar-agent-launch.spec.ts @@ -0,0 +1,90 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + configureGoldenStubAgent, + getGoldenStubAgentLaunchEnv, + GOLDEN_STUB_AGENTS, + GOLDEN_STUB_READY_MARKER, + launchGoldenStubAgentFromNewTab +} from './helpers/golden-stub-agent' +import { + getFirstWslDistro, + removeWslGoldenStubAgent, + stageWslGoldenStubAgent, + useWslRuntimeForActiveProject +} from './helpers/wsl-golden-stub-agent' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { getTerminalContent } from './helpers/terminal' +import type { BuiltInWindowsTerminalShell } from '../../src/shared/windows-terminal-shell' + +// Covers the Windows-only tab-bar launch path that the default-shell test misses. + +test.use({ launchEnv: getGoldenStubAgentLaunchEnv() }) + +const WINDOWS_SHELLS: readonly BuiltInWindowsTerminalShell[] = [ + 'powershell.exe', + 'cmd.exe', + 'git-bash' +] + +async function openWorkspaceTerminal(page: Page): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) +} + +for (const { id, menuItemName } of GOLDEN_STUB_AGENTS) { + test(`tab-bar + menu launches ${id} into a live TUI @tab-bar-agent-launch-golden`, async ({ + orcaPage + }) => { + await openWorkspaceTerminal(orcaPage) + await configureGoldenStubAgent(orcaPage, { agent: id }) + await launchGoldenStubAgentFromNewTab(orcaPage, menuItemName) + + const activeTab = orcaPage.locator('[data-testid="sortable-tab"][data-active="true"]') + await expect(activeTab).toHaveAttribute('data-tab-title', /Golden Stub Agent|Codex|Claude/i) + // The marker distinguishes an agent launch from an identical bare-shell tab. + expect(await getTerminalContent(orcaPage)).toContain(GOLDEN_STUB_READY_MARKER) + }) +} + +// Suite-level skipping avoids launching Electron for each unsupported case. +test.describe('Windows runtimes', () => { + test.skip(process.platform !== 'win32', 'Windows agent launch matrix is Windows-only') + + for (const shell of WINDOWS_SHELLS) { + test(`tab-bar + menu launches an agent under ${shell} @tab-bar-agent-launch-golden`, async ({ + orcaPage + }) => { + await openWorkspaceTerminal(orcaPage) + // Each shell family requires different launch-command quoting. + await configureGoldenStubAgent(orcaPage, { agent: 'codex', windowsShell: shell }) + await launchGoldenStubAgentFromNewTab(orcaPage) + + expect(await getTerminalContent(orcaPage)).toContain(GOLDEN_STUB_READY_MARKER) + }) + } + + test('tab-bar + menu launches an agent inside WSL @tab-bar-agent-launch-golden', async ({ + orcaPage + }) => { + await openWorkspaceTerminal(orcaPage) + + const distro = await getFirstWslDistro(orcaPage) + test.skip(!distro, 'No WSL distro is available on this Windows host') + const stage = stageWslGoldenStubAgent(distro!) + test.skip(!stage, 'WSL distro would not accept the staged stub agent') + + try { + // WSL must retarget both agent detection and the PTY. + await useWslRuntimeForActiveProject(orcaPage, distro!) + await configureGoldenStubAgent(orcaPage, { agent: 'codex' }) + await launchGoldenStubAgentFromNewTab(orcaPage) + + // The distro-only marker proves the agent ran in WSL. + expect(await getTerminalContent(orcaPage)).toContain(GOLDEN_STUB_READY_MARKER) + } finally { + removeWslGoldenStubAgent(distro!, stage!) + } + }) +}) diff --git a/tests/e2e/helpers/golden-stub-agent.ts b/tests/e2e/helpers/golden-stub-agent.ts index 62e44c9b091..5f01e691c53 100644 --- a/tests/e2e/helpers/golden-stub-agent.ts +++ b/tests/e2e/helpers/golden-stub-agent.ts @@ -2,10 +2,17 @@ import path from 'node:path' import type { Page } from '@stablyai/playwright-test' import { expect } from '@stablyai/playwright-test' import { focusActiveTerminalInput, waitForTerminalOutput } from './terminal' +import type { BuiltInWindowsTerminalShell } from '../../../src/shared/windows-terminal-shell' export const GOLDEN_STUB_READY_MARKER = 'GOLDEN_STUB_AGENT_READY' export const GOLDEN_STUB_EXIT_MARKER = 'GOLDEN_STUB_AGENT_EXITED' +/** Agents exposed by the fixture directory for tab-bar detection. */ +export const GOLDEN_STUB_AGENTS = [ + { id: 'codex', menuItemName: /^Codex(?:\s|$)/i }, + { id: 'claude', menuItemName: /^Claude(?:\s|$)/i } +] as const + const fixtureDir = path.join(process.cwd(), 'tests', 'e2e', 'fixtures', 'golden-stub-agent') export function getGoldenStubAgentLaunchEnv(): NodeJS.ProcessEnv { @@ -15,23 +22,38 @@ export function getGoldenStubAgentLaunchEnv(): NodeJS.ProcessEnv { } } -export async function configureGoldenStubAgent(page: Page): Promise { - await page.evaluate(async () => { - const store = window.__store - if (!store) { - throw new Error('Orca store is unavailable') - } - await store.getState().updateSettings({ - defaultTuiAgent: 'codex', - agentCmdOverrides: { codex: 'golden-stub-agent' }, - agentDefaultArgs: { codex: '' } - }) - }) +export async function configureGoldenStubAgent( + page: Page, + options: { + agent?: (typeof GOLDEN_STUB_AGENTS)[number]['id'] + /** Windows default shell the launch command must survive; ignored elsewhere. */ + windowsShell?: BuiltInWindowsTerminalShell + } = {} +): Promise { + const agent = options.agent ?? 'codex' + await page.evaluate( + async ({ agent, windowsShell }) => { + const store = window.__store + if (!store) { + throw new Error('Orca store is unavailable') + } + await store.getState().updateSettings({ + defaultTuiAgent: agent, + agentCmdOverrides: { [agent]: 'golden-stub-agent' }, + agentDefaultArgs: { [agent]: '' }, + ...(windowsShell ? { terminalWindowsShell: windowsShell } : {}) + }) + }, + { agent, windowsShell: options.windowsShell ?? null } + ) } -export async function launchGoldenStubAgentFromNewTab(page: Page): Promise { +export async function launchGoldenStubAgentFromNewTab( + page: Page, + menuItemName: RegExp = /^Codex(?:\s|$)/i +): Promise { await page.getByRole('button', { name: 'New tab' }).click({ force: true }) - const launchOption = page.getByRole('menuitem', { name: /^Codex(?:\s|$)/i }).first() + const launchOption = page.getByRole('menuitem', { name: menuItemName }).first() await expect(launchOption).toBeVisible({ timeout: 15_000 }) await launchOption.click({ force: true }) await focusActiveTerminalInput(page) diff --git a/tests/e2e/helpers/wsl-golden-stub-agent.ts b/tests/e2e/helpers/wsl-golden-stub-agent.ts new file mode 100644 index 00000000000..0b09644c14b --- /dev/null +++ b/tests/e2e/helpers/wsl-golden-stub-agent.ts @@ -0,0 +1,159 @@ +import { execFileSync } from 'node:child_process' +import type { Page } from '@stablyai/playwright-test' +import { buildWslExecArgs } from '../../../src/shared/wsl-login-shell-command' + +/** A WSL-only path makes the stub marker proof that the pane ran in the distro. */ +const WSL_STUB_PATH = '/usr/local/bin/golden-stub-agent' +const WSL_STUB_AGENT_LINK = '/usr/local/bin/codex' +const WSL_STUB_BACKUP_PATH = '/usr/local/bin/golden-stub-agent.orca-e2e-backup' +/** mkdir is atomic in the distro, so the lock dir serializes overlapping invocations. */ +const WSL_STUB_LOCK_PATH = '/usr/local/bin/golden-stub-agent.orca-e2e-lock' +const WSL_STUB_LINK_MARKER = `${WSL_STUB_LOCK_PATH}/created-codex-link` +const WSL_STUB_STAGED_MARKER = `${WSL_STUB_LOCK_PATH}/staged-stub` +const WSL_STUB_LOCK_STALE_MINUTES = 10 +const WSL_STUB_LOCK_WAIT_SECONDS = 60 + +// Undoes a lock holder that died mid-run, so its leftovers cannot poison later invocations. +const RECLAIM_STALE_LOCK_SCRIPT = + `if [ -e ${WSL_STUB_LINK_MARKER} ]; then rm -f ${WSL_STUB_AGENT_LINK}; fi; ` + + `if [ -e ${WSL_STUB_STAGED_MARKER} ]; then rm -f ${WSL_STUB_PATH}; fi; ` + + `if [ -e ${WSL_STUB_BACKUP_PATH} ] || [ -L ${WSL_STUB_BACKUP_PATH} ]; then ` + + `mv ${WSL_STUB_BACKUP_PATH} ${WSL_STUB_PATH}; fi; ` + + `rm -rf ${WSL_STUB_LOCK_PATH}` + +const ACQUIRE_LOCK_SCRIPT = + `mkdir -p /usr/local/bin || exit 1; i=0; ` + + `while [ $i -lt ${WSL_STUB_LOCK_WAIT_SECONDS} ]; do ` + + `if mkdir ${WSL_STUB_LOCK_PATH} 2>/dev/null; then printf acquired; exit 0; fi; ` + + `if [ -n "$(find ${WSL_STUB_LOCK_PATH} -maxdepth 0 -mmin +${WSL_STUB_LOCK_STALE_MINUTES} ` + + `2>/dev/null)" ]; then ${RECLAIM_STALE_LOCK_SCRIPT}; else sleep 1; fi; i=$((i+1)); ` + + `done; printf timeout` + +// Keep the cross-boundary script newline-free to avoid Windows argv-encoding surprises. +// Moving the entry avoids following and overwriting a pre-existing symlink. +const BACKUP_EXISTING_STUB_SCRIPT = + `mkdir -p /usr/local/bin && ` + + `if [ -e ${WSL_STUB_BACKUP_PATH} ] || [ -L ${WSL_STUB_BACKUP_PATH} ]; then exit 1; fi && ` + + `if [ -e ${WSL_STUB_PATH} ] || [ -L ${WSL_STUB_PATH} ]; then ` + + `mv ${WSL_STUB_PATH} ${WSL_STUB_BACKUP_PATH} && ` + + `printf backed-up; else printf none; fi` + +// The marker is written first so stale-lock recovery only removes a stub this helper wrote. +const STAGE_SCRIPT = + `mkdir -p /usr/local/bin && : > ${WSL_STUB_STAGED_MARKER} && ` + + `printf '#!/bin/sh\\necho GOLDEN_STUB_AGENT_READY\\nexec sleep 3600\\n' > ${WSL_STUB_PATH} && ` + + `chmod 0755 ${WSL_STUB_PATH}` + +// The marker is written before the link so a crashed run over-reports rather than leaks a link. +const STAGE_CODEX_LINK_IF_MISSING_SCRIPT = + `if [ -e ${WSL_STUB_AGENT_LINK} ] || [ -L ${WSL_STUB_AGENT_LINK} ]; then ` + + `printf existing; else : > ${WSL_STUB_LINK_MARKER} && ` + + `ln -s ${WSL_STUB_PATH} ${WSL_STUB_AGENT_LINK} && printf created; fi` + +// `;` between steps so the lock is released even when a restore step fails. +function buildRestoreScript(stage: WslGoldenStubAgentStage): string { + const steps: string[] = [] + if (stage.ownsStubPath) { + const removed = stage.createdCodexLink + ? `${WSL_STUB_AGENT_LINK} ${WSL_STUB_PATH}` + : WSL_STUB_PATH + steps.push(`rm -f ${removed}`) + if (stage.backedUpStub) { + steps.push(`mv ${WSL_STUB_BACKUP_PATH} ${WSL_STUB_PATH}`) + } + } + if (stage.heldLock) { + steps.push(`rm -rf ${WSL_STUB_LOCK_PATH}`) + } + return steps.join(' ; ') +} + +// --exec prevents wsl.exe from expanding shell variables in argv. +function runInWslAsRoot(distro: string, script: string): string { + return execFileSync( + 'wsl.exe', + ['-u', 'root', ...buildWslExecArgs(distro, ['sh', '-c', script])], + { encoding: 'utf8', stdio: 'pipe', windowsHide: true } + ) +} + +export async function getFirstWslDistro(page: Page): Promise { + const wsl = await page.evaluate(async () => ({ + available: await window.api.wsl.isAvailable(), + distros: await window.api.wsl.listDistros() + })) + return wsl.available ? (wsl.distros[0] ?? null) : null +} + +export type WslGoldenStubAgentStage = { + createdCodexLink: boolean + backedUpStub: boolean + ownsStubPath: boolean + heldLock: boolean +} + +/** Returns null when the distro cannot stage the stub. Holds a distro lock until cleanup. */ +export function stageWslGoldenStubAgent(distro: string): WslGoldenStubAgentStage | null { + const stage: WslGoldenStubAgentStage = { + createdCodexLink: false, + backedUpStub: false, + ownsStubPath: false, + heldLock: false + } + try { + if (runInWslAsRoot(distro, ACQUIRE_LOCK_SCRIPT).trim() !== 'acquired') { + return null + } + stage.heldLock = true + stage.backedUpStub = runInWslAsRoot(distro, BACKUP_EXISTING_STUB_SCRIPT).trim() === 'backed-up' + stage.ownsStubPath = true + runInWslAsRoot(distro, STAGE_SCRIPT) + stage.createdCodexLink = + runInWslAsRoot(distro, STAGE_CODEX_LINK_IF_MISSING_SCRIPT).trim() === 'created' + return stage + } catch { + removeWslGoldenStubAgent(distro, stage) + return null + } +} + +export function removeWslGoldenStubAgent(distro: string, stage: WslGoldenStubAgentStage): void { + const script = buildRestoreScript(stage) + if (!script) { + return + } + try { + runInWslAsRoot(distro, script) + stage.heldLock = false + stage.ownsStubPath = false + } catch { + // Best-effort cleanup; a leftover stub only affects this fixture's own name. + } +} + +/** Retargets project agent detection and terminal spawning to WSL. */ +export async function useWslRuntimeForActiveProject(page: Page, distro: string): Promise { + await page.evaluate(async (wslDistro) => { + const store = window.__store + if (!store) { + throw new Error('Orca store is unavailable') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('No active worktree') + } + const activeWorktree = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === worktreeId) + const activeProject = state.projects.find((project) => + activeWorktree ? project.sourceRepoIds.includes(activeWorktree.repoId) : false + ) + if (!activeProject) { + throw new Error('No active project') + } + await state.updateProject(activeProject.id, { + localWindowsRuntimePreference: { kind: 'wsl', distro: wslDistro } + }) + }, distro) +} From c618ec739376783bd167ed2691926e92af6ed097 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:38:46 -0700 Subject: [PATCH 05/12] test(reliability): protect recent P0 regression invariants (#16163) --- .github/workflows/pr.yml | 38 +- config/reliability-gates.jsonc | 416 +++++++++++++++- config/scripts/pr-e2e-gate-contract.test.mjs | 132 +++++- config/scripts/pr-e2e-source-routing.mjs | 117 +++++ ...ws-pty-native-capability-workflow.test.mjs | 70 +++ package.json | 1 + ...-prompt-submission-runtime-test-fixture.ts | 28 ++ .../agent-prompt-submission-runtime.test.ts | 44 +- ...-runtime-terminal-close-continuity.test.ts | 250 +++++++++- ...ation-worker-start-prompt-contract.test.ts | 277 +++++++++++ ...-connection-direct-ssh-spawn-retry.test.ts | 42 ++ .../agent-idle-working-handlers.ts | 11 +- .../pty-connection/connect-pane-pty.ts | 39 ++ .../deferred-session-reattach-choice.ts | 13 +- .../pty-connection/fresh-spawn-start.ts | 81 +++- .../pty-connection/pty-connect-limits.ts | 1 + .../pty-connection/terminal-tab-id.test.ts | 33 ++ .../pty-connection/terminal-tab-id.ts | 15 + .../pty-transport-connect-spawn.test.ts | 12 + .../terminal-pane/pty-transport-types.ts | 2 + .../components/terminal-pane/pty-transport.ts | 11 + .../terminal-pty-pre-spawn-e2e-barrier.ts | 72 +++ src/renderer/src/env.d.ts | 2 + .../terminal-tab-recovery-remount.test.ts | 14 + src/renderer/src/store/slices/terminals.ts | 5 + .../session/worktree-slice-lookups.ts | 13 +- tests/e2e/global-teardown.ts | 59 ++- tests/e2e/global-teardown.unit.test.ts | 52 ++ ...al-quick-command-pre-bind-recovery.spec.ts | 349 ++++++++++++++ .../README.md | 16 + .../packaged-node-pty-capability-oracle.mjs | 139 ++++++ ...ckaged-node-pty-capability-oracle.test.mjs | 98 ++++ .../packaged-node-pty-capability-probe.cjs | 448 ++++++++++++++++++ ...ackaged-node-pty-capability-probe.test.mjs | 89 ++++ .../real-orca-detached-launcher.vbs | 24 + .../run.mjs | 72 +++ .../run.test.mjs | 47 ++ 37 files changed, 2986 insertions(+), 146 deletions(-) create mode 100644 config/scripts/pr-e2e-source-routing.mjs create mode 100644 config/scripts/windows-pty-native-capability-workflow.test.mjs create mode 100644 src/main/runtime/agent-prompt-submission-runtime-test-fixture.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pty-pre-spawn-e2e-barrier.ts create mode 100644 tests/e2e/global-teardown.unit.test.ts create mode 100644 tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts create mode 100644 tests/tools/windows-pty-native-capability-smoke/README.md create mode 100644 tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.mjs create mode 100644 tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs create mode 100644 tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs create mode 100644 tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs create mode 100644 tests/tools/windows-pty-native-capability-smoke/real-orca-detached-launcher.vbs create mode 100644 tests/tools/windows-pty-native-capability-smoke/run.mjs create mode 100644 tests/tools/windows-pty-native-capability-smoke/run.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 01724348ba6..22fb956a1a5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -673,6 +673,9 @@ jobs: ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir + - name: Smoke packaged Windows PTY native capability + run: pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe + - name: Smoke packaged CLI run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked @@ -707,38 +710,9 @@ jobs: BASE="${{ github.event.pull_request.base.sha }}" HEAD="${{ github.event.pull_request.head.sha }}" CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")" - TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)" - if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then - TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)" - fi - # Why: the Docker-SSH specs only ever ran when someone edited a spec, so four - # pane-restore regressions shipped from SSH source edits that touched no test. - # Unit tests are excluded because they prove the same source without Docker. - # ssh-startup-exec-readiness.spec.ts is listed to route e2e.yml's dedicated - # ssh-docker-watcher-isolation lane, which filters it out of changed-e2e. - if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(src/main/ssh/|src/main/providers/ssh-|src/main/ipc/(ssh-|pty)|src/relay/|src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty))'; then - TEST_FILES="$(printf '%s\n' "$TEST_FILES" \ - 'tests/e2e/pty-input-write-queue-ssh.spec.ts' \ - 'tests/e2e/ssh-cold-activation-restore.spec.ts' \ - 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' \ - 'tests/e2e/ssh-startup-exec-readiness.spec.ts' \ - 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' | sort -u)" - fi - # Why: same lesson as the SSH specs above. The IME e2e suite asserts real pty bytes and - # real overlay geometry, but only ran when someone edited a spec, so every IME fix in the - # 1.4.18x window shipped without it - including one whose diagnosis hardware later refuted. - # config/patches is listed because the terminal's input handling lives in the xterm patch. - if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(config/patches/|src/renderer/src/components/terminal-pane/(terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))'; then - TEST_FILES="$(printf '%s\n' "$TEST_FILES" \ - 'tests/e2e/terminal-cjk-ime-committed-text.spec.ts' \ - 'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts' \ - 'tests/e2e/terminal-ime-exact-byte.spec.ts' \ - 'tests/e2e/terminal-korean-composing-chord-order.spec.ts' \ - 'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts' \ - 'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts' \ - 'tests/e2e/terminal-korean-preedit-visibility.spec.ts' | sort -u)" - fi - TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')" + # Source routes are executable contracts so a test can prove exact + # authorities, exclusions, and sentinels without evaluating workflow shell. + TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)" echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT" if [ "$TEST_FILES_JSON" != '[]' ]; then echo "should_run=true" >> "$GITHUB_OUTPUT" diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 97711a43088..c0fcd77c2fb 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-20", + "updatedAt": "2026-08-23", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -3680,14 +3680,16 @@ "providers": ["local", "daemon", "ssh", "runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon", "ssh", "runtime"], - "coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Deterministic tests cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending. A paired macOS Electron journey (real attached-window desktop host plus a separate real paired desktop client, isolated profiles) covers the inverse authority direction for host-created terminals: a renderer graph sync must not retire a tab that `orca terminal create` made. That claim now covers BOTH shapes, including the incident shape the real CLI produces - a repo's FIRST host-created terminal, since src/cli/handlers/terminal.ts sends no clientMutationId - each asserted by its own test. An earlier reading of this gate called a preceding host-created terminal and a `clientMutationId` protective; main-process instrumentation refuted that. The renderer's stale session write deleted the host tab from persistence in every variant, and only the delete's timing relative to the measured sync differed, so those rows were false negatives of a single-sync oracle. The defect was in persistence: persistPtyBinding did not raise the repo's terminal topology fence for the first host-admitted tab, leaving the renderer's pre-create tab list free to replay over it. Elapsed time and paired-client mirroring never decided anything. A windowless `orca serve` parity arm runs the identical oracle and passes on both the merge-base and the branch, confirming that topology does not exhibit the defect (the gate it turns on is window-conditioned) and that making the host binding unconditional did not regress it. All of this is macOS-local only. The reported incident occurred on a physical Windows host; we did not run against one. The claim that the mechanism is platform-independent rests on the authoritative-window gate and publication-epoch inheritance being OS-agnostic, not on Windows evidence, so no ConPTY, Windows window-lifecycle, update, or sleep behavior is covered here.", + "coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. A deterministic final-pane interleaving now proves durable tab retirement commits before externally owned PTY teardown can publish graph loss, and preserves an unrelated live canary through duplicate exit retirement. Deterministic tests also cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending. A paired macOS Electron journey (real attached-window desktop host plus a separate real paired desktop client, isolated profiles) covers the inverse authority direction for host-created terminals: a renderer graph sync must not retire a tab that `orca terminal create` made. That claim now covers BOTH shapes, including the incident shape the real CLI produces - a repo's FIRST host-created terminal, since src/cli/handlers/terminal.ts sends no clientMutationId - each asserted by its own test. An earlier reading of this gate called a preceding host-created terminal and a `clientMutationId` protective; main-process instrumentation refuted that. The renderer's stale session write deleted the host tab from persistence in every variant, and only the delete's timing relative to the measured sync differed, so those rows were false negatives of a single-sync oracle. The defect was in persistence: persistPtyBinding did not raise the repo's terminal topology fence for the first host-admitted tab, leaving the renderer's pre-create tab list free to replay over it. Elapsed time and paired-client mirroring never decided anything. A windowless `orca serve` parity arm runs the identical oracle and passes on both the merge-base and the branch, confirming that topology does not exhibit the defect (the gate it turns on is window-conditioned) and that making the host binding unconditional did not regress it. All of this is macOS-local only. The reported incident occurred on a physical Windows host; we did not run against one. The claim that the mechanism is platform-independent rests on the authoritative-window gate and publication-epoch inheritance being OS-agnostic, not on Windows evidence, so no ConPTY, Windows window-lifecycle, update, or sleep behavior is covered here.", "motivatingLinks": [ + "https://linear.app/stably/issue/STA-4327", + "https://github.com/stablyai/orca/pull/14590", "https://github.com/stablyai/orca/pull/8628", "https://github.com/stablyai/orca/pull/8706", "https://github.com/stablyai/orca/issues/14524" ], - "invariant": "Close permanently removes the owned provider session, agent descendants, persisted tab/layout authority, and resume authority even when no TerminalPane is mounted; final-pane CLI close does not acknowledge before durable tab retirement, and a persisted explicit-empty state prevents initial-tab fallback from repopulating the workspace after reload or restart. A handle remains valid while the same provider-attested PTY incarnation survives renderer reload or re-key, and becomes stale without adopting a replacement incarnation. A terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.", - "oracle": "Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Final-pane terminal.close must kill the exact PTY once, invoke one durability-acknowledged tab retirement, stay pending until the persisted row is removed, and never fall back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.", + "invariant": "Close permanently removes the owned provider session, agent descendants, persisted tab/layout authority, and resume authority even when no TerminalPane is mounted; final-pane CLI close commits durable tab retirement before PTY stop can publish graph loss, does not acknowledge before that retirement, and treats the later exit retirement as idempotent. A persisted explicit-empty state prevents initial-tab fallback from repopulating the workspace after reload or restart. A handle remains valid while the same provider-attested PTY incarnation survives renderer reload or re-key, and becomes stale without adopting a replacement incarnation. A terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.", + "oracle": "Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Final-pane terminal.close must invoke one durability-acknowledged tab retirement before exact PTY teardown; controlled PTY exit and graph removal afterward must remain idempotent, omit the target from persistence and provider inventory, and leave an unrelated canary handle live. The ordinary path stays pending until the persisted row is removed and never falls back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal/initial-terminal.test.ts src/renderer/src/lib/worktree-activation-default-tabs.test.ts src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts", @@ -3733,6 +3735,7 @@ "file": "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts", "assertions": [ "final-pane terminal.close remains pending until one renderer durability acknowledgement retires the persisted tab and layout", + "final-pane externally owned teardown commits session retirement before PTY stop, tolerates duplicate exit retirement, and preserves an unrelated live canary", "post-ack workspace-session loss falls back to renderer cleanup and verified PTY teardown while every pre-ack retirement error remains non-destructive", "renderer reload with the same provider-attested PTY incarnation preserves the exact handle", "same-id replacement incarnation publishes a new handle and makes the old handle deterministically stale", @@ -3913,7 +3916,7 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "STA-4327 used two byte-identical oracles. Runtime oracle cd248b67cc10ef64f2def315b86c3b0236b102639d03c65a0c5d9519e538d3af failed the same three durability/continuity checks on reported v1.4.180 (0b62333cf1), latest main (9bb8836bb6), and disabled fix; runtime candidate 3cf8a37447 passed 5/5, and final code candidate 121e112d77 separately passed its expanded 6/6 oracle. Renderer oracle 3b0949eef8b792cf5b09cfa55341f4216c08714f158e4b132e988e8114636cea failed both explicit-empty hydration/fallback checks on v1.4.180, latest main, and disabled fix, while renderer candidate 121e112d77 passed 2/2." + "evidence": "STA-4327 used two byte-identical oracles. Runtime oracle cd248b67cc10ef64f2def315b86c3b0236b102639d03c65a0c5d9519e538d3af failed the same three durability/continuity checks on reported v1.4.180 (0b62333cf1), latest main (9bb8836bb6), and disabled fix; runtime candidate 3cf8a37447 passed 5/5, and final code candidate 121e112d77 separately passed its expanded 6/6 oracle. Renderer oracle 3b0949eef8b792cf5b09cfa55341f4216c08714f158e4b132e988e8114636cea failed both explicit-empty hydration/fallback checks on v1.4.180, latest main, and disabled fix, while renderer candidate 121e112d77 passed 2/2. The 1.4.187 recurrence oracle deterministically failed with historical tab_not_found when PTY stop was moved ahead of durable retirement, then passed with retirement committed before externally owned teardown while the canary remained live." }, "performanceBudget": { "required": true, @@ -8047,6 +8050,7 @@ "remote-runtime host surface materialization", "remote-runtime mirror polling", "remote-runtime network recovery", + "paired client sleep/wake reconnect", "terminal create idempotency", "provider listing", "reattach", @@ -8059,6 +8063,8 @@ "coveredProviders": ["ssh", "remote-runtime"], "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.", "motivatingLinks": [ + "https://linear.app/stably/issue/STA-3107", + "https://github.com/stablyai/orca/pull/12664", "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", "https://github.com/stablyai/orca/pull/6979", @@ -8070,6 +8076,7 @@ "oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", @@ -8084,6 +8091,8 @@ ], "testFiles": [ "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts", + "src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts", + "src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts", "src/renderer/src/startup/ssh-startup-reconnect.test.ts", "src/renderer/src/lib/resolved-worktree-execution-host.test.ts", "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts", @@ -8142,6 +8151,20 @@ "tests/e2e/ssh-docker-relay-perf.spec.ts" ], "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts", + "assertions": [ + "six panes sharing one dropped multiplex connection each resubscribe to a distinct live host handle", + "a pane parked while the client slept is authoritatively activated and rematerialized without leaving any pane handle null" + ] + }, + { + "file": "src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts", + "assertions": [ + "after a long sleep every live host PTY and tab retains its client-owned sidebar agent row", + "unwritten or explicitly released pane authority still cedes deletion to the host" + ] + }, { "file": "src/renderer/src/startup/ssh-startup-reconnect.test.ts", "assertions": [ @@ -8471,6 +8494,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 2.54, + "summary": "Seven deterministic STA-3107 contracts passed across six-pane multiplex rematerialization and client-owned sidebar-row retention after sleep/wake." + }, { "date": "2026-07-14", "runner": "local", @@ -8554,7 +8586,7 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. On exact pre-fix HEAD 939719443, the split-pane store, fresh-spawn, and reattach oracles failed because the first success removed pending authority and the sibling could not bind; the same three oracles pass after live bindings retain the exact attempt ID. On exact committed HEAD d501f2e96, primary-exit-before-sibling and primary/non-primary split-detach oracles failed because live authority was deleted during the empty gap or transferred to only one resulting tab; all three pass after continuation-gap preservation and two-sided detach projection. On exact committed HEAD d44ea382b with test-only oracles, a pending-only all-null detach deleted the attempt ledger, a sibling mounted after first success committed without its lease, and that sibling could bind after authority rotation; all three pass after pending-only projection and retained-live-lease capture with provider retirement. On exact committed HEAD 9fa84dacf, the production-manager in-progress oracle rejected the fresh authority and the forward-removal barrier delayed stale transport cancellation; both pass after replacement starts cancellation immediately, shares concurrent fresh callers, and waits for teardown before connecting. On exact committed HEAD e5ba9a9e5, overlapping disconnect allowed a replacement connect before forward teardown completed and delayed transport cancellation behind that barrier; disconnect, removal, and terminate now share a target lifecycle barrier, start transport cancellation immediately, retain captured-session identity, and admit the replacement only after cleanup. On exact committed HEAD 9a29e7a81, a rejected forward teardown short-circuited the lifecycle while transport disconnect was pending, removal left its captured relay session alive, same-turn connect escaped admission, and reset remained outside the target barrier; the exact failure oracles pass after both cleanup branches settle, captured sessions always retire, admission is authority-fenced, and reset shares the barrier. On exact committed HEAD 47c7198f2, reset's remaining bespoke forward teardown could still reject after authority rotation but before captured-session retirement; reset now uses the same hardened session teardown and a failed reset remains cleanly retryable. Other direct SSH tests encode red conditions for non-atomic binding clear, cross-target retry, folder omission, duplicate same-authority attempts, hydration overwrite, terminal finalization delayed behind provider work, obsolete-authority pending-spawn adoption, Git lineage namespace mismatch, and snapshot PTY hint promotion without exact-attempt acknowledgement. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, paired-close, and a patched live remote-runtime partition journey before promotion." + "evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. For STA-3107, reverting #11542 in the six-pane multiplex harness leaves exactly one parked pane without a resubscribed handle while its siblings recover; before #12664, the first post-sleep host snapshot erases every stale client-owned sidebar row despite live tabs and host PTYs. The deterministic contracts pass after host activation rematerializes the missing pane and client-written ownership, rather than freshness, controls row retention. On exact pre-fix HEAD 939719443, the split-pane store, fresh-spawn, and reattach oracles failed because the first success removed pending authority and the sibling could not bind; the same three oracles pass after live bindings retain the exact attempt ID. On exact committed HEAD d501f2e96, primary-exit-before-sibling and primary/non-primary split-detach oracles failed because live authority was deleted during the empty gap or transferred to only one resulting tab; all three pass after continuation-gap preservation and two-sided detach projection. On exact committed HEAD d44ea382b with test-only oracles, a pending-only all-null detach deleted the attempt ledger, a sibling mounted after first success committed without its lease, and that sibling could bind after authority rotation; all three pass after pending-only projection and retained-live-lease capture with provider retirement. On exact committed HEAD 9fa84dacf, the production-manager in-progress oracle rejected the fresh authority and the forward-removal barrier delayed stale transport cancellation; both pass after replacement starts cancellation immediately, shares concurrent fresh callers, and waits for teardown before connecting. On exact committed HEAD e5ba9a9e5, overlapping disconnect allowed a replacement connect before forward teardown completed and delayed transport cancellation behind that barrier; disconnect, removal, and terminate now share a target lifecycle barrier, start transport cancellation immediately, retain captured-session identity, and admit the replacement only after cleanup. On exact committed HEAD 9a29e7a81, a rejected forward teardown short-circuited the lifecycle while transport disconnect was pending, removal left its captured relay session alive, same-turn connect escaped admission, and reset remained outside the target barrier; the exact failure oracles pass after both cleanup branches settle, captured sessions always retire, admission is authority-fenced, and reset shares the barrier. On exact committed HEAD 47c7198f2, reset's remaining bespoke forward teardown could still reject after authority rotation but before captured-session retirement; reset now uses the same hardened session teardown and a failed reset remains cleanly retryable. Other direct SSH tests encode red conditions for non-atomic binding clear, cross-target retry, folder omission, duplicate same-authority attempts, hydration overwrite, terminal finalization delayed behind provider work, obsolete-authority pending-spawn adoption, Git lineage namespace mismatch, and snapshot PTY hint promotion without exact-attempt acknowledgement. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, paired-close, and a patched live remote-runtime partition journey before promotion." }, "performanceBudget": { "required": true, @@ -9878,22 +9910,27 @@ "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local"], - "coverageNotes": "Local macOS evidence covers the runtime PTY write contract, live Codex orchestration, a dev-runtime CLI repro, and Claude and Codex composer traces. The render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.", + "coverageNotes": "Local macOS evidence covers the runtime PTY write contract, live Codex orchestration, a dev-runtime CLI repro, and Claude and Codex composer traces. A deterministic worker-start RPC/SQLite contract proves delayed Codex composer acceptance, exactly one submitted turn, durable Dispatch success, and explicit stalled failure when Enter is swallowed without a rescue send. The render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.", "motivatingLinks": [ + "https://linear.app/stably/issue/STA-3800", + "https://linear.app/stably/issue/STA-3904", + "https://linear.app/stably/issue/STA-4328", "https://github.com/stablyai/orca/issues/7226", "https://github.com/stablyai/orca/issues/13821", "https://github.com/stablyai/orca/issues/14347" ], - "invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.", - "oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, Claude/Codex marker-gated multi-frame renders, and the legacy platform delay for every other configured agent. The candidate resets settlement on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. Orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.", + "invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. A successful orchestration.workerStart must durably record exactly one accepted and started turn; a swallowed Enter must fail with agent_prompt_stalled and never trigger a blind rescue Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.", + "oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, Claude/Codex marker-gated multi-frame renders, and the legacy platform delay for every other configured agent. The candidate resets settlement on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. The worker-start contract drives the production RPC through a delayed fake Codex composer and independently checks exact turn/Enter counts plus reopened SQLite Task, Dispatch, worker receipt, and mutation receipt state for accepted and swallowed outcomes. Other orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts --reporter=dot", "node tests/tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000" ], "testFiles": [ "src/shared/agent-prompt-injection.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts", + "src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts", "src/main/runtime/orchestration/coordinator.test.ts", "tests/tools/repro-orchestration-long-prompt.mjs" ], @@ -9928,6 +9965,13 @@ "failed prompt injection rolls back the active dispatch" ] }, + { + "file": "src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts", + "assertions": [ + "delayed composer readiness produces exactly one submitted and started turn with no premature Enter and durable ready receipts", + "a swallowed Enter records agent_prompt_stalled across Task, Dispatch, worker, and mutation receipts without a rescue Enter" + ] + }, { "file": "src/main/runtime/orchestration/coordinator.test.ts", "assertions": [ @@ -9945,6 +9989,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 21.84, + "summary": "Two deterministic worker-start RPC contracts passed with fake clocks and reopened SQLite receipts for one accepted turn and one swallowed-Enter stalled outcome." + }, { "date": "2026-08-14", "runner": "local", @@ -10001,7 +10054,7 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. Codex used the legacy platform delay before this candidate; the late-marker oracle proves the original deadline would pre-empt slow-host settlement without re-arming. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job." + "evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. Codex used the legacy platform delay before this candidate; the late-marker oracle proves the original deadline would pre-empt slow-host settlement without re-arming. The worker-start negative controls caught both premature submission before composer readiness and a false-ready swallowed Enter; the accepted candidate records one started turn, while the swallowed arm records agent_prompt_stalled without a rescue Enter. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job." }, "performanceBudget": { "required": true, @@ -14249,6 +14302,349 @@ "The legacy remote-uploads root is intentionally not reclaimed while a mixed-version relay may still own bytes there; eventual legacy-root retirement requires a separate compatibility decision." ], "demotionRule": "Demote if one live owner can remove another owner's bytes, cleanup exceeds 64 inspected entries per attempt, a lifecycle path retains its exact owned archive after settlement, or the process oracle flakes without an identified harness or product defect." + }, + { + "id": "terminal-startup.quick-command-pre-bind-recovery", + "title": "Quick Command survives a pre-bind terminal recovery", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-renderer-lifecycle", + "layer": "electron-ui-and-real-local-pty", + "surfaces": ["Quick Commands", "terminal pane recovery", "fresh PTY startup"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "A forced pre-spawn barrier makes one visible Quick Command cross a recovery remount before its successor PTY binds. Store and lifecycle contracts retain lower-layer ownership; the Electron sentinel adds the rendered affordance, real PTY, exact-once terminal marker, staged identity file, and unrelated sibling canary.", + "motivatingLinks": [ + "https://linear.app/stably/issue/STA-4876", + "https://github.com/stablyai/orca/pull/15630" + ], + "invariant": "A Quick Command selected for a new terminal remains owned by that tab through a recovery remount, executes exactly once only after the successor PTY binds, and never reaches an unrelated terminal.", + "oracle": "Arm the E2E-only pre-spawn barrier, click the visible Quick Command, force one recovery generation while the original spawn is blocked, and release it. Require one exact semantic xterm marker, an independently written pane/tab identity file matching the bound live PTY, queue consumption only after bind, and a live interactive sibling canary.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/store/slices/terminal-startup-command-retention.test.ts --reporter=dot", + "pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts", + "src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts", + "src/renderer/src/store/slices/terminal-startup-command-retention.test.ts", + "tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/store/slices/terminal-startup-command-retention.test.ts", + "assertions": ["recovery remount retains the exact queued startup command"] + }, + { + "file": "tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts", + "assertions": [ + "the visible Quick Command executes exactly once in the intended fresh PTY", + "queue consumption follows PTY binding and the sibling terminal remains live" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "durationSeconds": 23, + "summary": "The exact rebased PR head passed the headless Electron flow in 23.0 seconds after an E2E-mode build." + }, + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "durationSeconds": 24, + "summary": "The exact rebased PR head passed the visible-window Electron flow in 23.9 seconds using the same E2E build." + } + ], + "runtimeBudget": { + "p95Seconds": 90, + "scope": "one isolated Electron app, one forced recovery remount, and two local PTYs" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "The new sentinel has local headless and forced-headful evidence but no routed CI soak history." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical spec failed with pending:null before PTY bind when the pre-6e25a900 eager-consume behavior was restored, then passed after restoration." + }, + "performanceBudget": { + "required": true, + "evidence": "The barrier exists only in E2E builds, adds no production timer or polling, and the live sentinel completed in under 20 seconds locally." + }, + "promotionCriteria": [ + "Collect 100 consecutive routed CI passes over at least 14 days without an unexplained flake.", + "Keep retries at zero and retain both semantic and identity oracles.", + "Collect Linux Electron evidence before promoting the focused lane to blocking." + ], + "knownGaps": [ + "Windows, Linux, SSH, WSL, and paired-runtime topologies are not covered because they were not causal to STA-4876.", + "The sentinel remains advisory while the repository-wide E2E signal is red." + ], + "demotionRule": "Demote if the barrier can be bypassed nondeterministically, either independent oracle is removed, the sibling canary is no longer isolated, or one unexplained flake occurs." + }, + { + "id": "quick-open.paired-host-path-search", + "title": "Paired Quick Open searches bounded host paths", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-filesystem", + "layer": "host-file-search-and-paired-runtime", + "surfaces": ["Quick Open", "paired desktop runtime", "headless runtime host"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "paired-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "paired-runtime"], + "coverageNotes": "Focused contracts cover bounded host search, ignore traversal, RPC response bounds, renderer cancellation, deterministic ordering, and transport payload size. Headed and headless paired-runtime journeys use an 88,763-file fixture and are source-routed into advisory PR E2E; physical SSH and Windows remain gaps.", + "motivatingLinks": [ + "https://linear.app/stably/issue/STA-4354", + "https://github.com/stablyai/orca/pull/15158" + ], + "invariant": "Quick Open queries execute on the worktree host, find exact ignored-path matches beyond the legacy inventory prefix, return deterministic bounded results, and keep the paired runtime usable without overflowing its outbound reply buffer.", + "oracle": "Create 88,763 paths with 77,792 ignored entries and a nominal 22 GiB sparse file, query exact late gitignored and .orcaignored targets through headed and headless paired runtimes, and require one deterministic match, a payload below 4 KiB, and a healthy follow-up worktree call.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-search-file-paths.test.ts src/main/runtime/orca-runtime-files-search.test.ts src/main/runtime/rpc/methods/files-path-search.test.ts src/renderer/src/components/quick-open-file-list.react.test.tsx src/renderer/src/runtime/runtime-file-client-search-listing.test.ts src/shared/quick-open-transport-budget.test.ts --reporter=dot", + "pnpm exec playwright test tests/e2e/paired-quick-open-large-tree.spec.ts --config tests/playwright.config.ts --workers=1" + ], + "testFiles": [ + "src/main/ipc/filesystem-search-file-paths.test.ts", + "src/main/runtime/orca-runtime-files-search.test.ts", + "src/main/runtime/rpc/methods/files-path-search.test.ts", + "src/renderer/src/components/quick-open-file-list.react.test.tsx", + "src/renderer/src/runtime/runtime-file-client-search-listing.test.ts", + "src/shared/quick-open-transport-budget.test.ts", + "tests/e2e/paired-quick-open-large-tree.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/filesystem-search-file-paths.test.ts", + "assertions": [ + "host search stays bounded and respects gitignore plus .orcaignore traversal" + ] + }, + { + "file": "src/renderer/src/runtime/runtime-file-client-search-listing.test.ts", + "assertions": ["paired path queries stay deterministic and bounded across transport"] + }, + { + "file": "tests/e2e/paired-quick-open-large-tree.spec.ts", + "assertions": ["headed and headless hosts find late ignored targets and remain callable"] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-search-file-paths.test.ts src/main/runtime/orca-runtime-files-search.test.ts src/main/runtime/rpc/methods/files-path-search.test.ts src/renderer/src/components/quick-open-file-list.react.test.tsx src/renderer/src/runtime/runtime-file-client-search-listing.test.ts src/shared/quick-open-transport-budget.test.ts --reporter=dot", + "durationSeconds": 5, + "summary": "Six focused files passed 52 tests across host search, runtime RPC, renderer query ownership, and transport bounds; existing React act warnings remained non-fatal." + } + ], + "runtimeBudget": { + "p95Seconds": 300, + "scope": "focused deterministic contracts plus two serial paired Electron large-tree journeys" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "The sentinel is newly source-routed for PR collection; no route-specific soak history exists yet." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The merged STA-4354 oracle places both targets beyond the removed inventory prefix; the old client-side prefix inventory misses them, while host search returns the exact deterministic paths." + }, + "performanceBudget": { + "required": true, + "evidence": "Search is query-driven, result-limited, cancellation-aware, and payload-bounded; PR routing selects this fixture only for its exact production authorities or direct spec edits." + }, + "promotionCriteria": [ + "Collect 100 consecutive routed CI passes or 14 days without an unexplained flake.", + "Collect physical SSH and Windows paired-runtime evidence.", + "Keep deterministic result hashes, payload bounds, and post-query runtime health green." + ], + "knownGaps": [ + "The live large-tree evidence uses local headed and headless hosts rather than physical SSH.", + "Windows path-search tooling and filesystem behavior are not represented by the local evidence." + ], + "demotionRule": "Demote if the routed fixture flakes without a diagnosed product or harness cause, returns unbounded payloads, misses either late ignored target, or leaves the paired runtime unhealthy." + }, + { + "id": "terminal-session.host-cold-park-stream-continuity", + "title": "Host cold parking preserves paired terminal streams", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime-graph", + "layer": "renderer-runtime-graph-and-terminal-stream", + "surfaces": ["host terminal cold park", "paired remote viewer", "multi-pane runtime graph"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local-daemon", "ssh-daemon", "paired-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local-daemon", "paired-runtime"], + "coverageNotes": "Deterministic policy and multiplex tests separate renderer parking from authoritative stream liveness, while runtime-graph tests cover exact parked leaf, pane runtime ID, title, multi-pane active-leaf, and disposal behavior. One headed paired journey proves input and echoed output while the host pane remains cold-parked.", + "motivatingLinks": [ + "https://linear.app/stably/issue/STA-2854", + "https://github.com/stablyai/orca/pull/15514" + ], + "invariant": "Cold parking a host renderer pane never retires its live PTY's runtime-graph leaf or interrupts a paired subscriber's stream, input, reconnect, pane identity, or multi-pane routing; the leaf retires only when exact PTY ownership ends.", + "oracle": "Cold-park a host-owned pane while a paired client actively views it, require the host manager to unmount, then type through the client and require the same PTY to receive and echo the token without any disconnected sample. Separately publish multi-pane parked leaves with exact pane runtime IDs and require per-PTY disposal to remove only the retired leaf.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts tests/e2e/host-cold-park-remote-subscriber.unit.test.ts --reporter=dot", + "pnpm exec playwright test tests/e2e/host-parked-pane-remote-viewer.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", + "src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts", + "tests/e2e/host-cold-park-remote-subscriber.unit.test.ts", + "tests/e2e/host-parked-pane-remote-viewer.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts", + "assertions": [ + "a live parked watcher publishes the exact leaf, pane runtime ID, and title", + "multi-pane publication never names an unsent active leaf and exact disposal retires ownership" + ] + }, + { + "file": "tests/e2e/host-cold-park-remote-subscriber.unit.test.ts", + "assertions": [ + "a fully unmounted host renderer still streams, accepts input, and resubscribes" + ] + }, + { + "file": "tests/e2e/host-parked-pane-remote-viewer.spec.ts", + "assertions": [ + "a cold-parked host pane carries a complete paired-client input and echo round trip" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts tests/e2e/host-cold-park-remote-subscriber.unit.test.ts --reporter=dot", + "durationSeconds": 8, + "summary": "Three focused files passed 64 watcher, multi-pane runtime-graph, cold-park policy, and authoritative-stream tests." + } + ], + "runtimeBudget": { + "p95Seconds": 180, + "scope": "focused deterministic contracts plus one serial headed paired cold-park journey" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "The existing sentinel is newly source-routed for PR collection; route-specific CI history has not started." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "On the affected STA-2854 path, cold parking removed the runtime-graph leaf and the paired stream disconnected; retaining the exact live parked watcher leaf keeps every sampled client phase connected and completes input plus echo." + }, + "performanceBudget": { + "required": true, + "evidence": "Publication reads the existing bounded parked-watcher registry and adds no timer, polling, provider scan, subprocess, or wire field; the 90-second live timeline is selected only by exact parking and graph authorities." + }, + "promotionCriteria": [ + "Collect 100 consecutive routed CI passes or 14 days without an unexplained flake.", + "Collect Linux, Windows, WSL, and physical SSH cold-park evidence.", + "Keep exact per-leaf disposal, multi-pane identity, and full client round-trip assertions green." + ], + "knownGaps": [ + "The live paired cold-park journey is macOS-only and uses a local daemon PTY.", + "Physical SSH, WSL, Linux, Windows, and host-restart cold-park journeys are not recorded." + ], + "demotionRule": "Demote if a live parked PTY loses its exact graph leaf, a retired PTY remains published, multi-pane identity drifts, or the routed client round trip flakes without a diagnosed cause." + }, + { + "id": "terminal-session.remote-pane-layout-retry", + "title": "Remote pane layouts retry identical state after reconnect", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-layout-sync", + "layer": "renderer-paired-runtime-layout", + "surfaces": ["paired terminal split layout", "pane title", "runtime reconnect"], + "platforms": ["macos", "linux", "windows"], + "providers": ["paired-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["paired-runtime"], + "coverageNotes": "Pure contracts cover identity-equal local write bailouts and remote push deduplication, while a headless-host paired Electron journey proves that an identical local layout is retried after a failed disconnected push and becomes visible to an independent observer.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/12420"], + "invariant": "Identity-equal pane layout writes avoid local churn but never suppress a remote retry whose prior push failed; reconnecting and repeating the same layout persists it on the host and publishes it to other paired clients.", + "oracle": "Disconnect a paired client, change a pane title, and observe the failed host push while local state retains the layout. Reconnect without remounting, submit the identical title again, and require the headless host plus a second paired observer to receive it.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts --reporter=dot", + "pnpm exec playwright test tests/e2e/paired-remote-pane-layout-retry.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts", + "src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts", + "tests/e2e/paired-remote-pane-layout-retry.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts", + "assertions": ["only successfully pushed layouts deduplicate later remote writes"] + }, + { + "file": "src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts", + "assertions": ["identity-equal layout and timer writes avoid redundant local updates"] + }, + { + "file": "tests/e2e/paired-remote-pane-layout-retry.spec.ts", + "assertions": [ + "an identical layout retries after reconnect and reaches a second paired observer" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts --reporter=dot", + "durationSeconds": 5, + "summary": "Two focused files passed 20 remote-push and identity-bailout contracts." + } + ], + "runtimeBudget": { + "p95Seconds": 90, + "scope": "two focused renderer contracts plus one serial headless-host paired journey" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "The existing paired sentinel is newly source-routed and has no route-specific CI soak history." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The affected implementation cached the disconnected layout before host acknowledgment and suppressed the identical retry; the merged oracle keeps only successful remote pushes deduplicated and the observer receives the retried title." + }, + "performanceBudget": { + "required": true, + "evidence": "Successful identity-equal writes remain O(1) bailouts; one failed remote push permits one later identical retry, with no polling, timer, provider scan, subprocess, or new wire field." + }, + "promotionCriteria": [ + "Collect 100 consecutive routed CI passes or 14 days without an unexplained flake.", + "Collect Linux and Windows paired-runtime reconnect evidence.", + "Keep local identity bailouts and failed-push retry ownership green." + ], + "knownGaps": [ + "The paired reconnect journey has only local macOS evidence.", + "The sentinel changes a pane title within an existing layout; concurrent split and close conflicts remain separate coverage." + ], + "demotionRule": "Demote if a failed push suppresses an identical retry, a successful equal write resumes redundant churn, or the routed observer journey flakes without a diagnosed cause." } ] } diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 92faae907a1..877b9b07c08 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -1,11 +1,17 @@ import { existsSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' +import { parse as parseJsonc } from 'jsonc-parser' import { describe, expect, it } from 'vitest' -import { parse } from 'yaml' +import { parse as parseYaml } from 'yaml' +import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' const projectDir = resolve(import.meta.dirname, '../..') -const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) -const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8')) +const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) +const e2eWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8')) +const reliabilityManifest = parseJsonc( + readFileSync(join(projectDir, 'config/reliability-gates.jsonc'), 'utf8') +) +const playwrightConfig = readFileSync(join(projectDir, 'tests/playwright.config.ts'), 'utf8') const sshDockerRunner = readFileSync( join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'), 'utf8' @@ -69,8 +75,15 @@ describe('PR E2E gate contract', () => { it('selects modified Playwright specs without running deleted tests', () => { expect(filterStep.run).toContain('--diff-filter=AMCR') - expect(filterStep.run).toContain("'^tests/e2e/.*\\.spec\\.ts$'") + expect(filterStep.run).toContain('config/scripts/pr-e2e-source-routing.mjs') expect(filterStep.run).not.toContain('tests/playwright\\.') + expect( + selectPrE2eSpecs([ + 'tests/e2e/active-view-restart-restore.spec.ts', + 'tests/e2e/deleted.spec.ts.bak', + 'tests/e2e/global-teardown.unit.test.ts' + ]) + ).toEqual(['tests/e2e/active-view-restart-restore.spec.ts']) }) it('uses one runner for changed specs and keeps full runs sharded', () => { @@ -89,6 +102,7 @@ describe('PR E2E gate contract', () => { expect(changedRun.run).toContain( 'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"' ) + expect(playwrightConfig).toContain('retries: 0') }) it('keeps startup-exec live parity in the isolated SSH lane', () => { @@ -121,7 +135,7 @@ describe('PR E2E gate contract', () => { ] for (const file of dedicatedWorkflows) { - const workflow = parse(readFileSync(join(projectDir, '.github/workflows', file), 'utf8')) + const workflow = parseYaml(readFileSync(join(projectDir, '.github/workflows', file), 'utf8')) expect(workflow.on.pull_request, file).toBeUndefined() } }) @@ -138,12 +152,14 @@ describe('PR E2E gate contract', () => { const sshSourceAuthorities = [ 'src/main/ssh/', 'src/main/providers/ssh-', - 'src/main/ipc/(ssh-|pty)', + 'src/main/ipc/pty', 'src/relay/', - 'src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty)' + 'src/renderer/src/components/terminal-pane/remote-runtime-' ] for (const authority of sshSourceAuthorities) { - expect(filterStep.run).toContain(authority) + expect(selectPrE2eSpecs([`${authority}routing.ts`])).toContain( + 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' + ) } const mappedSpecs = [ @@ -154,7 +170,7 @@ describe('PR E2E gate contract', () => { 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' ] for (const spec of mappedSpecs) { - expect(filterStep.run).toContain(`'${spec}'`) + expect(selectPrE2eSpecs(['src/main/ssh/connection.ts'])).toContain(spec) expect(existsSync(join(projectDir, spec)), spec).toBe(true) // Why: a spec that stops reading the flag would silently run without Docker. if (spec !== 'tests/e2e/ssh-startup-exec-readiness.spec.ts') { @@ -162,8 +178,7 @@ describe('PR E2E gate contract', () => { } } - // Why: unit tests next to the mapped sources prove the same code without Docker. - expect(filterStep.run).toContain("grep -Ev '\\.test\\.tsx?$'") + expect(selectPrE2eSpecs(['src/main/ssh/connection.test.ts'])).toEqual([]) // Why: startup readiness is filtered out of changed-e2e, so listing it is only // meaningful while it still routes the dedicated Docker lane. @@ -183,6 +198,99 @@ describe('PR E2E gate contract', () => { expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"') expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts') expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts') - expect(filterStep.run).toContain('ephemeral-vm-recipes|orca-yaml-hook-types') + expect(selectPrE2eSpecs(['src/shared/ephemeral-vm-recipes.ts'])).toEqual([ + 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' + ]) + }) + + it('routes P0 sentinels from their causal sources', () => { + const cases = [ + [ + 'src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx', + 'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts' + ], + ['src/main/runtime/orca-runtime-files.ts', 'tests/e2e/paired-quick-open-large-tree.spec.ts'], + [ + 'src/renderer/src/runtime/sync-runtime-graph.ts', + 'tests/e2e/host-parked-pane-remote-viewer.spec.ts' + ], + [ + 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts', + 'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts' + ], + [ + 'src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts', + 'tests/e2e/paired-remote-pane-layout-retry.spec.ts' + ] + ] + for (const [source, spec] of cases) { + expect(selectPrE2eSpecs([source]), source).toEqual([spec]) + expect(selectPrE2eSpecs([source.replace(/\.tsx?$/, '.test.ts')]), source).toEqual([]) + expect(existsSync(join(projectDir, spec)), spec).toBe(true) + } + const quickCommandSpec = 'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts' + for (const source of [ + 'src/renderer/src/components/terminal-pane/pty-connection.ts', + 'src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts', + 'src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts', + 'src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts', + 'src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts' + ]) { + expect(selectPrE2eSpecs([source]), source).toContain(quickCommandSpec) + expect(selectPrE2eSpecs([source.replace(/\.ts$/, '.test.ts')]), source).not.toContain( + quickCommandSpec + ) + } + for (const source of [ + 'src/main/ipc/rg-availability.ts', + 'src/shared/ripgrep-process-availability.ts' + ]) { + expect(selectPrE2eSpecs([source]), source).toEqual([ + 'tests/e2e/paired-quick-open-large-tree.spec.ts' + ]) + } + expect( + selectPrE2eSpecs([ + 'src/main/runtime/orca-runtime-files.ts', + 'tests/e2e/paired-quick-open-large-tree.spec.ts' + ]) + ).toEqual(['tests/e2e/paired-quick-open-large-tree.spec.ts']) + expect(selectPrE2eSpecs(['src/renderer/src/components/FileExplorer.tsx'])).toEqual([]) + expect( + selectPrE2eSpecs([ + 'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts' + ]) + ).toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts') + expect( + selectPrE2eSpecs([ + 'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-test-harness.ts' + ]) + ).not.toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts') + expect(selectPrE2eSpecs(['src/main/ipc/pty.ts'])).not.toContain( + 'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts' + ) + }) + + it('keeps source-routed sentinels registered to their reliability gates', () => { + const routedGateIds = [ + 'terminal-startup.quick-command-pre-bind-recovery', + 'quick-open.paired-host-path-search', + 'terminal-session.host-cold-park-stream-continuity', + 'terminal-provider.ssh-remote-reattach-contract', + 'terminal-session.remote-pane-layout-retry' + ] + for (const gateId of routedGateIds) { + const route = PR_E2E_SOURCE_ROUTES.find((candidate) => candidate.id === gateId) + const gate = reliabilityManifest.gates.find((candidate) => candidate.id === gateId) + expect(route, gateId).toBeDefined() + expect(gate, gateId).toMatchObject({ maturity: 'experimental', protection: 'partial' }) + for (const spec of route.specs) { + expect(gate.testFiles, gateId).toContain(spec) + expect( + gate.commands.some((command) => command.includes(spec)), + gateId + ).toBe(true) + } + } }) }) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs new file mode 100644 index 00000000000..4e429913c28 --- /dev/null +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -0,0 +1,117 @@ +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +const isProductSource = (file) => !/\.test\.tsx?$/.test(file) + +export const PR_E2E_SOURCE_ROUTES = [ + { + id: 'ephemeral-vm-runtime.rollback-readable-sidecar', + specs: ['tests/e2e/ephemeral-vm-provisioned-root.spec.ts'], + matches: (file) => + /^(?:src\/main\/ephemeral-vm-(?:runtime-(?:service|provisioning-persistence)|failed-start-cleanup)|src\/shared\/(?:ephemeral-vm-runtime-(?:store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$/.test( + file + ) + }, + { + id: 'ssh-terminal-source', + specs: [ + 'tests/e2e/pty-input-write-queue-ssh.spec.ts', + 'tests/e2e/ssh-cold-activation-restore.spec.ts', + 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', + 'tests/e2e/ssh-startup-exec-readiness.spec.ts', + 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' + ], + matches: (file) => + isProductSource(file) && + /^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test( + file + ) + }, + { + id: 'terminal-input.ime-and-synthetic-forwarding', + specs: [ + 'tests/e2e/terminal-cjk-ime-committed-text.spec.ts', + 'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts', + 'tests/e2e/terminal-ime-exact-byte.spec.ts', + 'tests/e2e/terminal-korean-composing-chord-order.spec.ts', + 'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts', + 'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts', + 'tests/e2e/terminal-korean-preedit-visibility.spec.ts' + ], + matches: (file) => + isProductSource(file) && + /^(?:config\/patches\/|src\/renderer\/src\/components\/terminal-pane\/(?:terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))/.test( + file + ) + }, + { + id: 'terminal-startup.quick-command-pre-bind-recovery', + specs: ['tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'], + matches: (file) => + isProductSource(file) && + /^(?:src\/renderer\/src\/components\/tab-bar\/TabBarQuickCommandsMenu\.tsx|src\/renderer\/src\/hooks\/use-terminal-quick-command-hosts\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:pty-connection|pty-transport|terminal-pty-pre-spawn-e2e-barrier)\.ts|src\/renderer\/src\/components\/terminal-pane\/pty-connection\/(?:connect-pane-pty|fresh-spawn-start|pane-pty-visibility-bind|pty-input-recovery)\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:TerminalPane|use-terminal-pane-lifecycle)\.tsx?|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test( + file + ) + }, + { + id: 'quick-open.paired-host-path-search', + specs: ['tests/e2e/paired-quick-open-large-tree.spec.ts'], + matches: (file) => + isProductSource(file) && + /^(?:src\/main\/ipc\/(?:filesystem-(?:list-files|search-file-paths)|rg-availability)\.ts|src\/main\/providers\/(?:filesystem-provider-contract|ssh-filesystem-provider(?:-capabilities)?)\.ts|src\/main\/runtime\/(?:orca-runtime-files|rpc\/methods\/files)\.ts|src\/relay\/(?:fs-handler(?:-install-rg|-list-files|-ripgrep-fallback)?|fs-list-files-fallback-chain)\.ts|src\/renderer\/src\/(?:components\/(?:QuickOpen|quick-open-file-list|quick-open-search)\.tsx?|runtime\/(?:runtime-file-client|runtime-legacy-quick-open-inventory)\.ts)|src\/shared\/(?:quick-open-(?:install-rg|path-search|transport-budget)|ripgrep-process-availability)\.ts)$/.test( + file + ) + }, + { + id: 'terminal-session.host-cold-park-stream-continuity', + specs: ['tests/e2e/host-parked-pane-remote-viewer.spec.ts'], + matches: (file) => + isProductSource(file) && + /^(?:src\/renderer\/src\/components\/terminal-pane\/(?:terminal-hidden-view-parking|terminal-tab-park-candidates|terminal-tab-activation-order|terminal-parked-pty-watcher|terminal-parked-tab-watchers|terminal-parked-watcher-registry)\.ts|src\/renderer\/src\/runtime\/sync-runtime-graph\.ts)$/.test( + file + ) + }, + { + id: 'terminal-provider.ssh-remote-reattach-contract', + specs: ['tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'], + matches: (file) => + isProductSource(file) && + !file.endsWith('-test-harness.ts') && + /^(?:src\/renderer\/src\/components\/terminal-pane\/remote-runtime-pty-transport(?:-[a-z0-9-]+)?\.ts|src\/renderer\/src\/runtime\/remote-runtime-terminal-multiplexer\.ts)$/.test( + file + ) + }, + { + id: 'terminal-session.remote-pane-layout-retry', + specs: ['tests/e2e/paired-remote-pane-layout-retry.spec.ts'], + matches: (file) => + isProductSource(file) && + /^(?:src\/renderer\/src\/components\/terminal-pane\/(?:remote-pane-layout-push|TerminalPane)\.tsx?|src\/renderer\/src\/lib\/terminal-layout-equality\.ts|src\/renderer\/src\/runtime\/web-session-tabs-sync\.ts|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test( + file + ) + } +] + +export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) { + const specs = new Set(changedPaths.filter((file) => /^tests\/e2e\/.*\.spec\.ts$/.test(file))) + for (const route of PR_E2E_SOURCE_ROUTES) { + const matchedFiles = changedPaths.filter(route.matches) + if (matchedFiles.length === 0) { + continue + } + route.specs.forEach((spec) => specs.add(spec)) + reportRoute(`[pr-e2e] ${route.id}: ${route.specs.join(', ')}`) + } + return [...specs].sort((left, right) => left.localeCompare(right)) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + let input = '' + process.stdin.setEncoding('utf8') + for await (const chunk of process.stdin) { + input += chunk + } + const changedPaths = input.split(/\r?\n/).filter(Boolean) + const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message)) + process.stdout.write(`${JSON.stringify(specs)}\n`) +} diff --git a/config/scripts/windows-pty-native-capability-workflow.test.mjs b/config/scripts/windows-pty-native-capability-workflow.test.mjs new file mode 100644 index 00000000000..9a6b8b285cf --- /dev/null +++ b/config/scripts/windows-pty-native-capability-workflow.test.mjs @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) +const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) + +describe('packaged Windows PTY native capability routing', () => { + it('runs the packaged executable immediately after the unpacked app is built', () => { + const job = workflow.jobs.package_windows + const packageIndex = job.steps.findIndex((step) => step.name === 'Package unpacked app') + const smokeIndex = job.steps.findIndex( + (step) => step.name === 'Smoke packaged Windows PTY native capability' + ) + const smoke = job.steps[smokeIndex] + + expect(job['runs-on']).toBe('windows-2022') + expect(smokeIndex).toBe(packageIndex + 1) + expect(smoke.run).toBe( + 'pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe' + ) + expect(smoke.if).toBeUndefined() + expect(smoke['continue-on-error']).toBeUndefined() + expect(packageJson.scripts['smoke:windows-pty-native-capability']).toBe( + 'node tests/tools/windows-pty-native-capability-smoke/run.mjs' + ) + }) + + it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => { + const job = workflow.jobs.package_windows + const sourceRebuild = job.steps.find( + (step) => step.name === 'Rebuild node-pty from patched source' + ) + const build = job.steps.find((step) => step.name === 'Build package inputs') + const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime') + const packageStep = job.steps.find((step) => step.name === 'Package unpacked app') + const verify = workflow.jobs.verify.steps.find( + (step) => step.name === 'Require successful checks' + ) + + expect(sourceRebuild.env.npm_config_build_from_source).toBe('true') + expect(sourceRebuild.run).toBe('pnpm rebuild node-pty') + expect(build.run).toBe('pnpm run build:release:parallel') + expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron') + expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1') + expect(workflow.jobs.verify.needs).toContain('package_windows') + expect(verify.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}') + expect(verify.run).toContain('"$PACKAGE_WINDOWS"') + }) + + it('keeps the native probe event-based, scoped, and runnable in packaged Node mode', () => { + const driver = readFileSync('tests/tools/windows-pty-native-capability-smoke/run.mjs', 'utf8') + const probe = readFileSync( + 'tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs', + 'utf8' + ) + const source = `${driver}\n${probe}` + + expect(driver).toContain("ELECTRON_RUN_AS_NODE: '1'") + expect(source).not.toMatch(/\b(?:sleep|tasklist|taskkill)\b/i) + expect(source).not.toContain('maxRetries') + expect(source).not.toContain('retryDelay') + expect(source).not.toContain("from 'node:child_process'") + expect(source).not.toContain("require('node:child_process')") + expect(probe).toContain("'System32', 'wscript.exe'") + expect(probe).toContain('real-orca-detached-launcher.vbs') + expect(probe).not.toMatch(/cmd\.exe|start "" \/b/i) + expect(probe).toContain('native.terminateJob(target._pty, target.pid)') + }) +}) diff --git a/package.json b/package.json index 66d0d0bf53e..933741f8cc4 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "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 tests/tools/win-update-e2e/run.mjs", "win-crash-survival-e2e": "node tests/tools/win-crash-survival-e2e/run.mjs", + "smoke:windows-pty-native-capability": "node tests/tools/windows-pty-native-capability-smoke/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", "test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs", diff --git a/src/main/runtime/agent-prompt-submission-runtime-test-fixture.ts b/src/main/runtime/agent-prompt-submission-runtime-test-fixture.ts new file mode 100644 index 00000000000..69470ac6148 --- /dev/null +++ b/src/main/runtime/agent-prompt-submission-runtime-test-fixture.ts @@ -0,0 +1,28 @@ +import type { TuiAgent } from '../../shared/tui-agent' +import { OrcaRuntimeService } from './orca-runtime' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' + +export const AGENT_PROMPT_TEST_WORKTREE_PATH = '/tmp/worktree-a' +export const AGENT_PROMPT_TEST_WORKTREE_ID = 'repo-1::/tmp/worktree-a' + +export async function createAgentPromptSubmissionRuntime( + onWrite: (runtime: OrcaRuntimeService, data: string, writeIndex: number) => void, + launchAgent: TuiAgent = 'aider' +): Promise<{ runtime: OrcaRuntimeService; handle: string; writes: string[] }> { + const runtime = new OrcaRuntimeService(makeStore() as never) + const writes: string[] = [] + runtime.setPtyController({ + spawn: async () => ({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + onWrite(runtime, data, writes.length) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const terminal = await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, { + launchAgent + }) + return { runtime, handle: terminal.handle, writes } +} diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 711e887b5c5..f26ff8030b8 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -3,10 +3,14 @@ import { AGENT_PROMPT_BRACKETED_PASTE_END, AGENT_PROMPT_SUBMIT_DELAY_MS } from '../../shared/agent-prompt-injection' +import { + AGENT_PROMPT_TEST_WORKTREE_PATH, + createAgentPromptSubmissionRuntime +} from './agent-prompt-submission-runtime-test-fixture' import { OrcaRuntimeService } from './orca-runtime' import { makeStore } from './runtime-rpc-worktree-store-fixtures' -const WORKTREE_PATH = '/tmp/worktree-a' +const createPromptRuntime = createAgentPromptSubmissionRuntime vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue([ @@ -29,37 +33,18 @@ vi.mock('../git/worktree', () => ({ ]) })) -async function createPromptRuntime( - onWrite: (runtime: OrcaRuntimeService, data: string, writeIndex: number) => void -): Promise<{ runtime: OrcaRuntimeService; handle: string; writes: string[] }> { - const runtime = new OrcaRuntimeService(makeStore() as never) - const writes: string[] = [] - runtime.setPtyController({ - spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), - write: (_ptyId, data) => { - writes.push(data) - onWrite(runtime, data, writes.length) - return true - }, - kill: () => true, - getForegroundProcess: async () => null - }) - const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, { - launchAgent: 'aider' - }) - return { runtime, handle: terminal.handle, writes } -} - describe('agent prompt submission runtime', () => { afterEach(() => vi.useRealTimers()) it('submits exactly once after an observed lifecycle transition', async () => { vi.useFakeTimers() - const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { - if (data === '\r') { - runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime( + (runtime, data) => { + if (data === '\r') { + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + } } - }) + ) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') await vi.runAllTimersAsync() @@ -374,8 +359,11 @@ describe('agent prompt submission runtime', () => { kill: () => true, getForegroundProcess: async () => null }) - handle = (await runtime.createTerminal(`path:${WORKTREE_PATH}`, { launchAgent: 'aider' })) - .handle + handle = ( + await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, { + launchAgent: 'aider' + }) + ).handle runtime.onPtyData( 'pty-prompt', 'Permission required\nAllow once\nAllow always\nReject\n' + diff --git a/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts b/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts index c3a3e747b01..320f27d6c03 100644 --- a/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts +++ b/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts @@ -10,13 +10,62 @@ const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` const TAB_ID = 'tab-close-continuity' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const SIBLING_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const CANARY_TAB_ID = 'tab-close-continuity-canary' +const CANARY_LEAF_ID = '55555555-5555-4555-8555-555555555555' const PTY_ID = 'pty-close-continuity' const RUNTIME_OWNED_PTY_ID = 'serve-close-continuity' const SIBLING_PTY_ID = 'pty-close-continuity-sibling' +const CANARY_PTY_ID = 'pty-close-continuity-canary' const INCARNATION_ID = '22222222-2222-4222-8222-222222222222' const SIBLING_INCARNATION_ID = '44444444-4444-4444-8444-444444444444' +const CANARY_INCARNATION_ID = '66666666-6666-4666-8666-666666666666' +const canarySessionTab = { + id: CANARY_TAB_ID, + ptyId: CANARY_PTY_ID, + worktreeId: WORKTREE_ID, + title: 'Canary shell', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 2 +} +const canarySessionLayout = { + root: { type: 'leaf' as const, leafId: CANARY_LEAF_ID }, + activeLeafId: CANARY_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [CANARY_LEAF_ID]: CANARY_PTY_ID } +} +const canarySyncedTab = { + tabId: CANARY_TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Canary shell', + activeLeafId: CANARY_LEAF_ID, + layout: { type: 'leaf' as const, leafId: CANARY_LEAF_ID } +} +const canarySyncedLeaf = { + tabId: CANARY_TAB_ID, + worktreeId: WORKTREE_ID, + leafId: CANARY_LEAF_ID, + paneRuntimeId: 9, + ptyId: CANARY_PTY_ID +} +const canaryMobileTab = { + type: 'terminal' as const, + id: `${CANARY_TAB_ID}::${CANARY_LEAF_ID}`, + parentTabId: CANARY_TAB_ID, + leafId: CANARY_LEAF_ID, + ptyId: CANARY_PTY_ID, + title: 'Canary shell', + isActive: false +} +const canaryProcess = { + id: CANARY_PTY_ID, + incarnationId: CANARY_INCARNATION_ID, + cwd: WORKTREE_PATH, + title: 'Canary shell' +} -function makeSession(ptyId = PTY_ID): WorkspaceSessionState { +function makeSession(ptyId = PTY_ID, includeCanary = false): WorkspaceSessionState { return { ...getDefaultWorkspaceSession(), tabsByWorktree: { @@ -30,7 +79,8 @@ function makeSession(ptyId = PTY_ID): WorkspaceSessionState { color: null, sortOrder: 0, createdAt: 1 - } + }, + ...(includeCanary ? [canarySessionTab] : []) ] }, terminalLayoutsByTabId: { @@ -39,10 +89,14 @@ function makeSession(ptyId = PTY_ID): WorkspaceSessionState { activeLeafId: LEAF_ID, expandedLeafId: null, ptyIdsByLeafId: { [LEAF_ID]: ptyId } - } + }, + ...(includeCanary ? { [CANARY_TAB_ID]: canarySessionLayout } : {}) }, terminalPtyIncarnationsByPaneKey: { - [makePaneKey(TAB_ID, LEAF_ID)]: INCARNATION_ID + [makePaneKey(TAB_ID, LEAF_ID)]: INCARNATION_ID, + ...(includeCanary + ? { [makePaneKey(CANARY_TAB_ID, CANARY_LEAF_ID)]: CANARY_INCARNATION_ID } + : {}) } } } @@ -56,13 +110,19 @@ function makeDeferred() { } function createHarness( - options: { ptyId?: string; publishMobileSurface?: boolean; registerPtyBacked?: boolean } = {} + options: { + ptyId?: string + publishMobileSurface?: boolean + registerPtyBacked?: boolean + includeCanary?: boolean + } = {} ) { const ptyId = options.ptyId ?? PTY_ID - let session = makeSession(ptyId) + let session = makeSession(ptyId, options.includeCanary) let sessionAvailable = true let incarnationId = INCARNATION_ID let includeSiblingPty = false + let victimPtyListed = true const repo = { id: REPO_ID, path: WORKTREE_PATH, @@ -80,29 +140,40 @@ function createHarness( getWorkspaceSession: () => (sessionAvailable ? session : undefined), setWorkspaceSession: (next: WorkspaceSessionState) => { session = next - } + }, + flushOrThrow: () => {} } const acknowledged = makeDeferred() let closeTerminalTabError: Error | null = null + let closeTerminalTabAction: (() => void | Promise) | null = null const closeTerminal = vi.fn() - const closeTerminalTab = vi.fn(() => - closeTerminalTabError ? Promise.reject(closeTerminalTabError) : acknowledged.promise - ) + const closeTerminalTab = vi.fn(() => { + if (closeTerminalTabError) { + return Promise.reject(closeTerminalTabError) + } + return closeTerminalTabAction ? Promise.resolve(closeTerminalTabAction()) : acknowledged.promise + }) const kill = vi.fn(() => true) let verifiedStopResult: boolean | Error = false - const stopAndWait = vi.fn(async () => { + let stopAndWaitAction: ((stoppingPtyId: string) => void | Promise) | null = null + const stopAndWait = vi.fn(async (stoppingPtyId: string) => { + await stopAndWaitAction?.(stoppingPtyId) if (verifiedStopResult instanceof Error) { throw verifiedStopResult } return verifiedStopResult }) const listProcesses = vi.fn(async () => [ - { - id: ptyId, - incarnationId, - cwd: WORKTREE_PATH, - title: 'Fixture shell' - }, + ...(victimPtyListed + ? [ + { + id: ptyId, + incarnationId, + cwd: WORKTREE_PATH, + title: 'Fixture shell' + } + ] + : []), ...(includeSiblingPty ? [ { @@ -112,7 +183,8 @@ function createHarness( title: 'Fixture sibling shell' } ] - : []) + : []), + ...(options.includeCanary ? [canaryProcess] : []) ]) const runtime = new OrcaRuntimeService(store as never) runtime.setNotifier({ closeTerminal, closeTerminalTab } as never) @@ -134,7 +206,8 @@ function createHarness( title: 'Fixture shell', activeLeafId: LEAF_ID, layout: { type: 'leaf', leafId: LEAF_ID } - } + }, + ...(options.includeCanary ? [canarySyncedTab] : []) ], leaves: [ { @@ -143,7 +216,8 @@ function createHarness( leafId: LEAF_ID, paneRuntimeId: 7, ptyId - } + }, + ...(options.includeCanary ? [canarySyncedLeaf] : []) ], ...(options.publishMobileSurface ? { @@ -164,13 +238,34 @@ function createHarness( ptyId, title: 'Fixture shell', isActive: true - } + }, + ...(options.includeCanary ? [canaryMobileTab] : []) ] } ] } : {}) }) + const syncCanaryGraph = () => + runtime.syncWindowGraph(1, { + tabs: [canarySyncedTab], + leaves: [canarySyncedLeaf], + ...(options.publishMobileSurface + ? { + mobileSessionTabs: [ + { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer:close-continuity', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: `${CANARY_TAB_ID}::${CANARY_LEAF_ID}`, + activeTabType: 'terminal' as const, + tabs: [{ ...canaryMobileTab, isActive: true }] + } + ] + } + : {}) + }) const syncEmptyGraph = () => runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) const syncFixtureTabWithoutLeaf = () => runtime.syncWindowGraph(1, { @@ -181,9 +276,10 @@ function createHarness( title: 'Fixture shell', activeLeafId: LEAF_ID, layout: { type: 'leaf', leafId: LEAF_ID } - } + }, + ...(options.includeCanary ? [canarySyncedTab] : []) ], - leaves: [] + leaves: options.includeCanary ? [canarySyncedLeaf] : [] }) const syncSplitFixtureGraph = () => { includeSiblingPty = true @@ -245,6 +341,13 @@ function createHarness( leafId: LEAF_ID, incarnationId: INCARNATION_ID }) + if (options.includeCanary) { + runtime.registerPty(CANARY_PTY_ID, WORKTREE_ID, null, { + tabId: CANARY_TAB_ID, + leafId: CANARY_LEAF_ID, + incarnationId: CANARY_INCARNATION_ID + }) + } } syncFixtureGraph() return { @@ -254,6 +357,7 @@ function createHarness( closeTerminalTab, kill, stopAndWait, + syncCanaryGraph, syncEmptyGraph, syncFixtureGraph, syncFixtureTabWithoutLeaf, @@ -262,20 +366,41 @@ function createHarness( makeSessionUnavailable: () => { sessionAvailable = false }, + removeVictimFromInventory: () => { + victimPtyListed = false + }, retirePersistedTab: () => { + const victimPaneKey = makePaneKey(TAB_ID, LEAF_ID) session = { ...session, - tabsByWorktree: { ...session.tabsByWorktree, [WORKTREE_ID]: [] }, - terminalLayoutsByTabId: {}, - terminalPtyIncarnationsByPaneKey: {} + tabsByWorktree: { + ...session.tabsByWorktree, + [WORKTREE_ID]: (session.tabsByWorktree[WORKTREE_ID] ?? []).filter( + (tab) => tab.id !== TAB_ID + ) + }, + terminalLayoutsByTabId: Object.fromEntries( + Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => tabId !== TAB_ID) + ), + terminalPtyIncarnationsByPaneKey: Object.fromEntries( + Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).filter( + ([paneKey]) => paneKey !== victimPaneKey + ) + ) } }, + setCloseTerminalTabAction: (action: () => void | Promise) => { + closeTerminalTabAction = action + }, rejectTerminalTabClose: (error: Error) => { closeTerminalTabError = error }, setVerifiedStopResult: (result: boolean | Error) => { verifiedStopResult = result }, + setStopAndWaitAction: (action: (stoppingPtyId: string) => void | Promise) => { + stopAndWaitAction = action + }, replaceIncarnation: (next: string) => { incarnationId = next } @@ -317,6 +442,79 @@ describe('terminal close and handle incarnation continuity', () => { expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([]) }) + it('fences final-pane exit ordering until exact retirement commits', async () => { + const harness = createHarness({ + includeCanary: true, + publishMobileSurface: true, + registerPtyBacked: true + }) + harness.syncFixtureTabWithoutLeaf() + const closeMobileSessionTab = vi.spyOn(harness.runtime, 'closeMobileSessionTab') + const order: string[] = [] + harness.setCloseTerminalTabAction(() => { + order.push('session-retirement') + harness.retirePersistedTab() + harness.syncCanaryGraph() + order.push('committed-graph-removal') + }) + harness.setVerifiedStopResult(true) + harness.setStopAndWaitAction((stoppingPtyId) => { + order.push('pty-stop') + harness.removeVictimFromInventory() + harness.syncCanaryGraph() + order.push('idempotent-graph-removal') + harness.runtime.onPtyExit(stoppingPtyId, 0, INCARNATION_ID) + order.push('idempotent-session-retirement') + }) + const terminals = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals + const victim = terminals.find((terminal) => terminal.ptyId === PTY_ID)! + const canary = terminals.find((terminal) => terminal.ptyId === CANARY_PTY_ID)! + + await expect(harness.runtime.closeTerminal(victim.handle)).resolves.toMatchObject({ + handle: victim.handle, + tabId: TAB_ID, + ptyKilled: true + }) + + expect(order).toEqual([ + 'session-retirement', + 'committed-graph-removal', + 'pty-stop', + 'idempotent-graph-removal', + 'idempotent-session-retirement' + ]) + expect(harness.stopAndWait).toHaveBeenCalledTimes(1) + expect(harness.stopAndWait).toHaveBeenCalledWith(PTY_ID, { + deadlineMs: expect.any(Number) + }) + expect(harness.kill).not.toHaveBeenCalled() + expect(closeMobileSessionTab).toHaveBeenCalledTimes(1) + expect(closeMobileSessionTab).toHaveBeenCalledWith(`id:${WORKTREE_ID}`, TAB_ID, { + localPtyTeardownOwnedExternally: true + }) + expect(harness.closeTerminalTab).toHaveBeenCalledTimes(1) + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: CANARY_TAB_ID, ptyId: CANARY_PTY_ID }) + ]) + expect(harness.getSession().terminalLayoutsByTabId).toEqual({ + [CANARY_TAB_ID]: expect.objectContaining({ + ptyIdsByLeafId: { [CANARY_LEAF_ID]: CANARY_PTY_ID } + }) + }) + expect(harness.getSession().terminalPtyIncarnationsByPaneKey).toEqual({ + [makePaneKey(CANARY_TAB_ID, CANARY_LEAF_ID)]: CANARY_INCARNATION_ID + }) + const survivors = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals + expect(survivors).toHaveLength(1) + const [survivor] = survivors + expect(survivor).toMatchObject({ ptyId: CANARY_PTY_ID }) + await expect(harness.runtime.readTerminal(survivor.handle)).resolves.toMatchObject({ + handle: survivor.handle, + status: 'running' + }) + expect(canary.ptyId).toBe(survivor.ptyId) + }) + it('does not kill the final PTY when durable tab retirement is rejected', async () => { const harness = createHarness() const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts new file mode 100644 index 00000000000..89455f9cc0a --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts @@ -0,0 +1,277 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AGENT_PROMPT_BRACKETED_PASTE_END } from '../../../../shared/agent-prompt-injection' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' +import { + AGENT_PROMPT_TEST_WORKTREE_ID, + createAgentPromptSubmissionRuntime +} from '../../agent-prompt-submission-runtime-test-fixture' +import { OrchestrationDb } from '../../orchestration/db' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { ORCHESTRATION_METHODS } from './orchestration' + +vi.mock('../../../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/prompt-contract', + isBare: false, + isMainWorktree: false + } + ]), + listWorktreesStrict: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/prompt-contract', + isBare: false, + isMainWorktree: false + } + ]) +})) + +const COORDINATOR_PANE_KEY = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const WORKER_PANE_KEY = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const REQUEST_ID = 'worker_start_prompt_contract' +const openDatabases: OrchestrationDb[] = [] +const temporaryRoots: string[] = [] + +type PromptContractHarness = { + db: OrchestrationDb + dbPath: string + dispatcher: RpcDispatcher + request: RpcRequest + requestId: string + taskId: string + submittedTurns: () => number + startedTurns: () => number + prematureSubmits: () => number + writes: string[] +} + +async function createPromptContractHarness( + outcome: 'accepted' | 'swallowed' +): Promise { + let composerReady = false + let submittedTurns = 0 + let startedTurns = 0 + let prematureSubmits = 0 + const fixture = await createAgentPromptSubmissionRuntime((runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + setTimeout(() => runtime.onPtyData('pty-prompt', 'partial composer frame', Date.now()), 650) + setTimeout(() => runtime.onPtyData('pty-prompt', '\x1b[?25h', Date.now()), 750) + setTimeout(() => { + composerReady = true + runtime.onPtyData('pty-prompt', 'final composer frame', Date.now()) + }, 1_000) + return + } + if (data !== '\r') { + return + } + submittedTurns += 1 + if (!composerReady) { + prematureSubmits += 1 + } + if (outcome === 'accepted') { + startedTurns += 1 + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + } + }, 'codex') + const { runtime, handle } = fixture + runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) + + const temporaryRoot = mkdtempSync(join(tmpdir(), 'orca-worker-prompt-contract-')) + temporaryRoots.push(temporaryRoot) + const dbPath = join(temporaryRoot, 'orchestration.db') + const db = new OrchestrationDb(dbPath) + openDatabases.push(db) + runtime.setOrchestrationDb(db) + const run = db.createRun({ + objective: 'Worker prompt contract', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: COORDINATOR_PANE_KEY + }) + const task = db.createTask({ spec: 'start exactly one worker turn', runId: run.id }) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((candidate) => + candidate === 'term_coord' + ? COORDINATOR_PANE_KEY + : candidate === handle + ? WORKER_PANE_KEY + : null + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((candidate) => + candidate === handle ? `runtime_test:${handle}:1` : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ + handle: 'term_coord', + worktreeId: 'repo::parent', + status: 'running' + } as never) + vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ + id: 'repo::parent', + repoId: 'repo-1' + } as never) + vi.spyOn(runtime, 'showRepo').mockResolvedValue({ id: 'repo-1', kind: 'git' } as never) + vi.spyOn(runtime, 'createManagedWorktree').mockResolvedValue({ + worktree: { id: AGENT_PROMPT_TEST_WORKTREE_ID, repoId: 'repo-1' }, + startupTerminal: { spawned: true, handle }, + setupReceipt: { + requested: 'run', + hookFound: false, + startupPolicy: 'start-immediately', + state: 'not_configured' + } + } as never) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + + return { + db, + dbPath, + dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }), + request: { + id: `rpc_${outcome}`, + authToken: 'caller-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: `${REQUEST_ID}_${outcome}`, + method: 'orchestration.workerStart', + params: { + task: task.id, + from: 'term_coord', + worktree: 'new-child', + name: `prompt-contract-${outcome}`, + agent: 'codex' + } + }, + requestId: `${REQUEST_ID}_${outcome}`, + taskId: task.id, + submittedTurns: () => submittedTurns, + startedTurns: () => startedTurns, + prematureSubmits: () => prematureSubmits, + writes: fixture.writes + } +} + +function reopenPromptContractDb(harness: PromptContractHarness): OrchestrationDb { + const index = openDatabases.indexOf(harness.db) + if (index !== -1) { + openDatabases.splice(index, 1) + } + harness.db.close() + harness.db = new OrchestrationDb(harness.dbPath) + openDatabases.push(harness.db) + return harness.db +} + +describe('orchestration worker-start prompt contract', () => { + afterEach(() => { + for (const db of openDatabases.splice(0)) { + db.close() + } + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + vi.useRealTimers() + }) + + it('durably accepts exactly one submitted and started turn', async () => { + vi.useFakeTimers() + const harness = await createPromptContractHarness('accepted') + const pending = harness.dispatcher.dispatch(harness.request) + + await vi.runAllTimersAsync() + const response = await pending + expect(response).toMatchObject({ + ok: true, + result: { + state: 'ready', + stage: 'input_accepted', + mutation: { requestId: harness.requestId, replayed: false }, + effects: expect.arrayContaining([ + expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' }) + ]) + } + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const dispatchId = (response.result as { dispatchId: string }).dispatchId + expect(harness.submittedTurns()).toBe(1) + expect(harness.startedTurns()).toBe(1) + expect(harness.prematureSubmits()).toBe(0) + expect(harness.writes.filter((data) => data === '\r')).toHaveLength(1) + const persisted = reopenPromptContractDb(harness) + expect(persisted.getTask(harness.taskId)?.status).toBe('dispatched') + expect(persisted.getDispatchContextById(dispatchId)).toMatchObject({ + status: 'dispatched', + capability_hash: expect.any(String), + capability_revoked_at: null + }) + expect(persisted.getWorkerDispatch(dispatchId)).toMatchObject({ + state: 'ready', + stage: 'input_accepted', + last_error: null + }) + const callerFingerprint = persisted.getOrCreateLocalMutationCallerFingerprint() + const receipt = persisted.getMutationReceipt(callerFingerprint, harness.requestId) + expect(receipt).toMatchObject({ state: 'completed' }) + expect(JSON.parse(receipt?.receipt ?? 'null')).toMatchObject({ + dispatchId, + state: 'ready', + stage: 'input_accepted' + }) + }) + + it('reports a swallowed Enter as stalled without sending a rescue Enter', async () => { + vi.useFakeTimers() + const harness = await createPromptContractHarness('swallowed') + const pending = harness.dispatcher.dispatch(harness.request) + + await vi.runAllTimersAsync() + const response = await pending + expect(response).toMatchObject({ + ok: true, + result: { + state: 'failed', + failedStage: 'dispatch_input', + lastError: 'agent_prompt_stalled', + mutation: { requestId: harness.requestId, replayed: false } + } + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const dispatchId = (response.result as { dispatchId: string }).dispatchId + await vi.advanceTimersByTimeAsync(20_000) + expect(harness.submittedTurns()).toBe(1) + expect(harness.startedTurns()).toBe(0) + expect(harness.prematureSubmits()).toBe(0) + expect(harness.writes.filter((data) => data === '\r')).toHaveLength(1) + const persisted = reopenPromptContractDb(harness) + expect(persisted.getTask(harness.taskId)?.status).toBe('failed') + expect(persisted.getDispatchContextById(dispatchId)).toMatchObject({ + status: 'failed', + last_failure: 'agent_prompt_stalled', + capability_revoked_at: expect.any(String) + }) + expect(persisted.getWorkerDispatch(dispatchId)).toMatchObject({ + state: 'failed', + stage: 'dispatch_input', + last_error: 'agent_prompt_stalled' + }) + const callerFingerprint = persisted.getOrCreateLocalMutationCallerFingerprint() + const receipt = persisted.getMutationReceipt(callerFingerprint, harness.requestId) + expect(receipt).toMatchObject({ state: 'completed' }) + expect(JSON.parse(receipt?.receipt ?? 'null')).toMatchObject({ + dispatchId, + state: 'failed', + failedStage: 'dispatch_input', + lastError: 'agent_prompt_stalled' + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts index 9017b94dc98..410f8afac54 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts @@ -6,6 +6,10 @@ import { createMockTransport, createPane, createManager } from './pty-connection import { buildPaneConnectionDeps, buildDirectSshSplitRetryCommit } from './pty-connection-test-deps' import { createInitialStoreState } from './pty-connection-test-store-fixtures' import type { StoreState } from './pty-connection-test-store-state' +import { + pendingSpawnByPaneKey, + pendingSpawnGenerationByPaneKey +} from './pty-connection/pty-connect-limits' import type { MockTransport } from './pty-connection-test-pane-fixtures' import { installTerminalTestGlobals, @@ -139,6 +143,8 @@ describe('connectPanePty', () => { }) afterEach(async () => { + pendingSpawnByPaneKey.clear() + pendingSpawnGenerationByPaneKey.clear() await restoreTerminalTestGlobals() }) @@ -161,6 +167,42 @@ describe('connectPanePty', () => { logSpy.mockRestore() }, 30_000) + it('does not hold a successor behind a canceled spawn serializer declaration', async () => { + const { connectPanePty } = await import('./pty-connection') + const declaration = createDeferred() + const spawn = createDeferred() + const transport = createMockTransport() + transport.connect.mockReturnValueOnce(spawn.promise) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] } + } + vi.mocked(window.api.pty.declarePendingPaneSerializer).mockReturnValueOnce(declaration.promise) + + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks(12) + binding.dispose() + spawn.resolve(null) + await flushAsyncTicks(12) + + expect(pendingSpawnByPaneKey.size).toBe(0) + expect(window.api.pty.clearPendingPaneSerializer).not.toHaveBeenCalled() + + declaration.resolve(7) + await flushAsyncTicks(12) + + expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledExactlyOnceWith( + expect.any(String), + 7 + ) + }) + it.each(['rejects', 'resolves empty'] as const)( 'settles the exact direct SSH retry when its fresh spawn %s', async (outcome) => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts index 523b77a32f1..7623bfbafc2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts @@ -151,9 +151,14 @@ export function installAgentIdleWorkingHandlers(session: ConnectPanePtySession): : undefined return attempt })() - session.pendingSpawnKey = session.directSshRetryAttempt - ? JSON.stringify([session.cacheKey, session.directSshRetryAttempt.attemptId]) - : session.cacheKey + // Generation is part of ownership: a recovery remount must not join a spawn + // started by the pane instance it replaced, while StrictMode remounts keep + // the same generation and may still share their in-flight spawn. + session.pendingSpawnKey = JSON.stringify([ + session.cacheKey, + session.tabGeneration, + session.directSshRetryAttempt?.attemptId ?? null + ]) session.capturedDirectSshRetryPtyAccepted = false session.directSshPaneRetrySettlementCancelled = false session.directSshPaneRetrySettlementTimers = new Set>() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts index c689b66f3ee..5b25adf6b51 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -1,4 +1,5 @@ import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' +import { useAppStore } from '@/store' import { TerminalKittyKeyboardModeTracker } from '../../../../../shared/terminal-kitty-keyboard-mode-tracker' import type { PtyConnectionDeps } from '../pty-connection-types' import { @@ -35,6 +36,7 @@ import { installPtyInputRecovery } from './pty-input-recovery' import { installPtyInputForward } from './pty-input-forward' import { installPtyResizeGeometry } from './pty-resize-geometry' import { installSessionReconcileDispose } from './session-reconcile-dispose' +import { resolveTerminalTabId } from './terminal-tab-id' /** * Establishes a binding between a terminal pane and its corresponding PTY stream, @@ -48,6 +50,43 @@ export function connectPanePty( const session = { pane, manager, deps } as ConnectPanePtySession session.shouldRefreshForegroundSynchronously = (): boolean => !session.manager.hasWebglRenderer(session.pane.id) + const state = useAppStore.getState() + const unifiedTab = state.getTab?.(deps.tabId) + const initialOwnerWorktreeId = + state.getTerminalTabOwnerWorktreeId?.(deps.tabId) ?? + (unifiedTab?.contentType === 'terminal' + ? state.getTerminalTabOwnerWorktreeId?.(unifiedTab.entityId) + : null) + const terminalTabId = resolveTerminalTabId( + { + getTab: state.getTab, + hasTerminalTab: (candidateId) => + Boolean( + state.tabsByWorktree[deps.worktreeId]?.some( + (candidate) => candidate.id === candidateId + ) || + (initialOwnerWorktreeId + ? state.tabsByWorktree[initialOwnerWorktreeId]?.some( + (candidate) => candidate.id === candidateId + ) + : false) + ) + }, + deps.tabId + ) + const ownerWorktreeId = + state.getTerminalTabOwnerWorktreeId?.(terminalTabId) ?? initialOwnerWorktreeId + const terminalTab = + state.tabsByWorktree[deps.worktreeId]?.find((candidate) => candidate.id === terminalTabId) ?? + (ownerWorktreeId + ? state.tabsByWorktree[ownerWorktreeId]?.find((candidate) => candidate.id === terminalTabId) + : undefined) ?? + // Why: folder/worktree migrations can leave the pane's render key stale for one commit. + Object.values(state.tabsByWorktree) + .find((tabs) => tabs.some((candidate) => candidate.id === terminalTabId)) + ?.find((candidate) => candidate.id === terminalTabId) + const tab = terminalTab ?? (unifiedTab && 'generation' in unifiedTab ? unifiedTab : null) + session.tabGeneration = tab?.generation ?? 0 // Why: recovery ownership belongs to this xterm instance. A request that // settles after remount must not remount its already-replaced successor. session.terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(session.deps.tabId) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts index 4d97ecf82c6..ca0da3e9731 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts @@ -3,7 +3,11 @@ import { useAppStore } from '@/store' import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import { getEagerPtyBufferHandle } from '../pty-dispatcher' -import { pendingSpawnByPaneKey, recordPtyConnectDiagnostic } from './pty-connect-limits' +import { + pendingSpawnByPaneKey, + pendingSpawnGenerationByPaneKey, + recordPtyConnectDiagnostic +} from './pty-connect-limits' import { isRemoteRuntimePtyId, canRestorePairedParkedTerminal, @@ -158,6 +162,13 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession) session.allowInitialIdleCacheSeed = false const pendingSpawn = pendingSpawnByPaneKey.get(session.pendingSpawnKey) if (pendingSpawn) { + const pendingGeneration = pendingSpawnGenerationByPaneKey.get(session.pendingSpawnKey) + if (pendingGeneration !== undefined && pendingGeneration !== session.tabGeneration) { + recordPtyConnectDiagnostic(`pane=${session.pane.id} -> STALE PENDING SPAWN`) + session.startFreshSpawn() + scheduleRuntimeGraphSync() + return + } recordPtyConnectDiagnostic(`pane=${session.pane.id} -> PENDING SPAWN`) session.armDirectSshPaneRetryTimeout(pendingSpawn, session.directSshRetryAttempt) void pendingSpawn diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index 3bbbf76f2d9..d60e94a160f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -3,7 +3,7 @@ import { hasPtySerializer } from '../pty-buffer-serializer' import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { STARTUP_CWD_FALLBACK_NOTICE } from './startup-cwd-fallback-notice' -import { pendingSpawnByPaneKey } from './pty-connect-limits' +import { pendingSpawnByPaneKey, pendingSpawnGenerationByPaneKey } from './pty-connect-limits' import { shouldWritePtyOutputForeground } from './foreground-output-scan' import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' import { toProcessExitStartup } from './process-exit-startup' @@ -14,6 +14,7 @@ import type { } from './fresh-spawn-types' import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { resolveTerminalTabId } from './terminal-tab-id' export function bindStartFreshSpawn(session: ConnectPanePtySession): void { session.startFreshSpawn = ( @@ -56,6 +57,15 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { const preSignalPromise = session.runtimeEnvironmentId ? Promise.resolve(null) : window.api.pty.declarePendingPaneSerializer(session.cacheKey).catch(() => null) + const clearPreSignaledSerializer = (): void => { + // A disposed pre-bind connect must not keep a successor behind a slow + // serializer declaration. Cleanup can finish independently of spawn ownership. + void preSignalPromise.then((gen) => { + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } + }) + } session.transportConnectInFlightSince = Date.now() const effectiveStartup = startupOverride === undefined ? session.paneStartup : startupOverride @@ -86,6 +96,51 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), ...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), + shouldContinue: () => { + const state = useAppStore.getState() + const unifiedTab = state.getTab?.(session.deps.tabId) + const initialOwnerWorktreeId = + state.getTerminalTabOwnerWorktreeId?.(session.deps.tabId) ?? + (unifiedTab?.contentType === 'terminal' + ? state.getTerminalTabOwnerWorktreeId?.(unifiedTab.entityId) + : null) + const terminalTabId = resolveTerminalTabId( + { + getTab: state.getTab, + hasTerminalTab: (candidateId) => + Boolean( + state.tabsByWorktree[session.deps.worktreeId]?.some( + (candidate) => candidate.id === candidateId + ) || + (initialOwnerWorktreeId + ? state.tabsByWorktree[initialOwnerWorktreeId]?.some( + (candidate) => candidate.id === candidateId + ) + : false) + ) + }, + session.deps.tabId + ) + const ownerWorktreeId = + state.getTerminalTabOwnerWorktreeId?.(terminalTabId) ?? initialOwnerWorktreeId + const terminalTab = + state.tabsByWorktree[session.deps.worktreeId]?.find( + (candidate) => candidate.id === terminalTabId + ) ?? + (ownerWorktreeId + ? state.tabsByWorktree[ownerWorktreeId]?.find( + (candidate) => candidate.id === terminalTabId + ) + : undefined) + const fallbackTab = Object.values(state.tabsByWorktree) + .find((tabs) => tabs.some((candidate) => candidate.id === terminalTabId)) + ?.find((candidate) => candidate.id === terminalTabId) + const currentTab = + terminalTab ?? + fallbackTab ?? + (unifiedTab && 'generation' in unifiedTab ? unifiedTab : null) + return !session.disposed && (currentTab?.generation ?? 0) === session.tabGeneration + }, callbacks: outputCallbacks.callbacks }) @@ -98,10 +153,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { .then(async (spawnedPtyId) => { if (outputCallbacks.generation !== session.transportStreamGeneration) { session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) - } + clearPreSignaledSerializer() return null } const resolvedPtyId = @@ -114,10 +166,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) // Why: an outstanding declare keeps main's cooperation gate suppressing // this paneKey's daemon-snapshot seed until something releases it. - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) - } + clearPreSignaledSerializer() return null } const connectResult = @@ -196,6 +245,11 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { if (resolvedPtyId) { session.reconcilePtySizeAfterSpawn(resolvedPtyId, session.cols, session.rows) } + if (!resolvedPtyId) { + clearPreSignaledSerializer() + session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) + return null + } const gen = await preSignalPromise // Why: a bound PTY owns the renderer serializer even when the declare was // rejected; the gen token only settles or clears the pending declaration. @@ -206,8 +260,6 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { if (typeof gen === 'number') { void window.api.pty.settlePaneSerializer(session.cacheKey, gen).catch(() => {}) } - } else if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) } if (resolvedPtyId && session.connectionId) { if ( @@ -229,15 +281,13 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { ) { session.clearRegisteredStartupLaunchConfig() } - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) - } + clearPreSignaledSerializer() return null }) .finally(() => { if (pendingSpawnByPaneKey.get(session.pendingSpawnKey) === trackedPromise) { pendingSpawnByPaneKey.delete(session.pendingSpawnKey) + pendingSpawnGenerationByPaneKey.delete(session.pendingSpawnKey) } }) session.armDirectSshPaneRetryTimeout(trackedPromise, session.directSshRetryAttempt) @@ -259,6 +309,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { // 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(session.pendingSpawnKey, trackedPromise) + pendingSpawnGenerationByPaneKey.set(session.pendingSpawnKey, session.tabGeneration) return trackedPromise } } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts index 82e850eef19..e082c5703c9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts @@ -1,6 +1,7 @@ import { e2eConfig } from '@/lib/e2e-config' export const pendingSpawnByPaneKey = new Map>() +export const pendingSpawnGenerationByPaneKey = new Map() export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' // Why: relay requests expire at 30s; leave one second for their fallback before re-arming locally. export const DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS = 31_000 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.test.ts new file mode 100644 index 00000000000..24292e7d85d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveTerminalTabId } from './terminal-tab-id' + +describe('resolveTerminalTabId', () => { + it('maps a unified terminal tab id to its legacy owner id', () => { + const getTab = vi.fn(() => ({ contentType: 'terminal', entityId: 'legacy-tab' })) + + expect(resolveTerminalTabId({ getTab }, 'unified-tab')).toBe('legacy-tab') + expect(getTab).toHaveBeenCalledWith('unified-tab') + }) + + it('keeps non-terminal and unknown ids unchanged', () => { + expect( + resolveTerminalTabId( + { getTab: () => ({ contentType: 'editor', entityId: '/tmp/file.ts' }) }, + 'editor-tab' + ) + ).toBe('editor-tab') + expect(resolveTerminalTabId({}, 'legacy-tab')).toBe('legacy-tab') + }) + + it('keeps an exact terminal owner ahead of a stale unified alias', () => { + expect( + resolveTerminalTabId( + { + getTab: () => ({ contentType: 'terminal', entityId: 'old-owner' }), + hasTerminalTab: (tabId) => tabId === 'live-owner' + }, + 'live-owner' + ) + ).toBe('live-owner') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts new file mode 100644 index 00000000000..c4e02f82af0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts @@ -0,0 +1,15 @@ +type TerminalTabLookup = { + getTab?: (tabId: string) => { contentType: string; entityId: string } | null + hasTerminalTab?: (tabId: string) => boolean +} + +/** Resolve a renderer tab id to the legacy terminal-tab id used by PTY state. */ +export function resolveTerminalTabId(state: TerminalTabLookup, tabId: string): string { + // The terminal-tab table is authoritative when both id forms are briefly present. + // A stale unified entry must not redirect a live PTY owner to an older entity id. + if (state.hasTerminalTab?.(tabId)) { + return tabId + } + const unifiedTab = state.getTab?.(tabId) + return unifiedTab?.contentType === 'terminal' ? unifiedTab.entityId : tabId +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts index 4abc51cb28c..66dca98b360 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts @@ -38,6 +38,18 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('does not create a PTY when the pane generation is stale', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const transport = createIpcPtyTransport({}) + + await expect( + transport.connect({ url: '', shouldContinue: () => false, callbacks: {} }) + ).resolves.toBeUndefined() + + expect(spawn).not.toHaveBeenCalled() + }) + it('threads provider command ownership through the spawn IPC', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const transport = createIpcPtyTransport({ diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index d8bdc63f62a..17f390f4ec4 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -154,6 +154,8 @@ export type PtyTransport = { startupCommandDelivery?: StartupCommandDelivery /** Reject a stale restored identity before this transport can publish global PTY handlers. */ admitPtyId?: (ptyId: string) => boolean + /** Reject a stale pane after any pre-spawn test gate but before creating a PTY. */ + shouldContinue?: () => boolean callbacks: PtyCallbacks }) => void | Promise attach: (options: { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index f713ee6c2f9..5d24ede0b4d 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -33,6 +33,7 @@ import { isPreHandlerPtyStateDiscarded } from './pty-pre-handler-buffer' import { createPtyInputWriteQueue } from './pty-input-write-queue' +import { waitAtTerminalPtyPreSpawnE2EBarrier } from './terminal-pty-pre-spawn-e2e-barrier' import type { PtyDataMeta } from './pty-dispatcher' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' import { createBellDetector } from '../../../../shared/terminal-bell-detector' @@ -807,6 +808,16 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } try { + const preSpawnBarrier = waitAtTerminalPtyPreSpawnE2EBarrier() + if (preSpawnBarrier) { + await preSpawnBarrier + if (destroyed) { + return + } + } + if (options.shouldContinue && !options.shouldContinue()) { + return + } // Why: cwd fallback is only for fresh local spawns — reattach keeps the session's cwd and SSH transports resolve cwd on the remote host. const shouldSendLocalCwdFallback = cwdFallback === 'worktree' && !connectionId && !admittedSessionId diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-pre-spawn-e2e-barrier.ts b/src/renderer/src/components/terminal-pane/terminal-pty-pre-spawn-e2e-barrier.ts new file mode 100644 index 00000000000..cc162e2ec91 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pty-pre-spawn-e2e-barrier.ts @@ -0,0 +1,72 @@ +import { e2eConfig } from '@/lib/e2e-config' + +type BarrierState = { + blocked: boolean + release: () => void + released: Promise + reportBlocked: () => void + blockedReported: Promise +} + +export type TerminalPtyPreSpawnE2EBarrier = { + arm: () => void + waitUntilBlocked: () => Promise + release: () => void + status: () => 'idle' | 'armed' | 'blocked' +} + +let barrier: BarrierState | null = null + +function createBarrier(): BarrierState { + let release = (): void => {} + let reportBlocked = (): void => {} + const released = new Promise((resolve) => { + release = resolve + }) + const blockedReported = new Promise((resolve) => { + reportBlocked = resolve + }) + return { blocked: false, release, released, reportBlocked, blockedReported } +} + +function exposeBarrier(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + window.__terminalPtyPreSpawnE2EBarrier ??= { + arm: () => { + if (barrier) { + throw new Error('Terminal PTY pre-spawn E2E barrier is already armed') + } + barrier = createBarrier() + }, + waitUntilBlocked: async () => { + if (!barrier) { + throw new Error('Terminal PTY pre-spawn E2E barrier is not armed') + } + await barrier.blockedReported + }, + release: () => { + barrier?.release() + }, + status: () => (barrier ? (barrier.blocked ? 'blocked' : 'armed') : 'idle') + } +} + +export function waitAtTerminalPtyPreSpawnE2EBarrier(): Promise | null { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return null + } + exposeBarrier() + const current = barrier + if (!current || current.blocked) { + return null + } + current.blocked = true + current.reportBlocked() + return current.released.then(() => { + if (barrier === current) { + barrier = null + } + }) +} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index e2ce09331d3..028ae6949f8 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -5,6 +5,7 @@ import type { OnboardingFeatureSetupDeps } from '@/components/onboarding/onboard import type { languages } from 'monaco-editor' import type { MonacoE2EProbe } from './components/editor/monaco-e2e-probe' import type { TerminalWorktreeParkingDebugVerdict } from './components/terminal-pane/terminal-parking-e2e-overrides' +import type { TerminalPtyPreSpawnE2EBarrier } from './components/terminal-pane/terminal-pty-pre-spawn-e2e-barrier' declare module 'monaco-editor/esm/vs/basic-languages/python/python.js' { export const conf: languages.LanguageConfiguration @@ -77,6 +78,7 @@ declare global { } __monacoEditorE2E?: MonacoE2EProbe __e2ePtyAppliedSizeReadDelayMs?: number + __terminalPtyPreSpawnE2EBarrier?: TerminalPtyPreSpawnE2EBarrier } } diff --git a/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts b/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts index 33a2fbce165..f4b28129251 100644 --- a/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts +++ b/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts @@ -28,6 +28,20 @@ describe('remountTerminalTabForRecovery', () => { expect(after?.pendingActivationSpawn).toBeTruthy() }) + it('gives a remounted pane a distinct queued startup owner', () => { + const store = createTestStore() + const tabId = seedWorktreeWithTab(store) + const startup = { command: 'echo recover' } + store.getState().queueTabStartupCommand(tabId, startup) + const before = store.getState().pendingStartupByTabId[tabId] + + expect(store.getState().remountTerminalTabForRecovery(tabId)).toBe(true) + + const after = store.getState().pendingStartupByTabId[tabId] + expect(after).toEqual(before) + expect(after).not.toBe(before) + }) + it('leaves sibling tabs untouched', () => { const store = createTestStore() const tabId = seedWorktreeWithTab(store) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 4e570420a11..49c2e7f00ed 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -649,6 +649,8 @@ export type TerminalSlice = { setTabBarOrder: (worktreeId: string, order: string[]) => void setActiveTab: (tabId: string) => void setActiveTabForWorktree: (worktreeId: string, tabId: string) => void + /** Resolve the canonical legacy terminal-tab owner key for renderer lifecycle guards. */ + getTerminalTabOwnerWorktreeId?: (tabId: string) => string | null updateTabTitle: (tabId: string, title: string) => void updateTabTitles: (updates: readonly TerminalTabTitleUpdate[]) => void setAiVaultTabTitle: (tabId: string, aiVaultTitle: AiVaultSessionTitle | null) => void @@ -1498,6 +1500,9 @@ export const createTerminalSlice: StateCreator return tab }, + getTerminalTabOwnerWorktreeId: (tabId) => + getTerminalTabOwnerWorktreeId(get().tabsByWorktree, tabId), + openNewTerminalTabInActiveWorkspace: async (groupId) => { const state = get() const worktreeId = state.activeWorktreeId diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index 112c4da1aff..f63712427c5 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -30,6 +30,7 @@ export function createRemountTerminalTabForRecovery( } const tab = tabs[index] const nextTabs = tabs.slice() + const pendingStartup = s.pendingStartupByTabId[tabId] nextTabs[index] = { ...tab, // Why: bump generation to remount a pane whose renderer died while its PTY stayed alive, so it reattaches, not spawns. @@ -44,7 +45,17 @@ export function createRemountTerminalTabForRecovery( tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: nextTabs - } + }, + ...(pendingStartup + ? { + // Why: a remounted pane must own a distinct one-shot startup record so a stale + // pane cannot consume the successor's command during teardown. + pendingStartupByTabId: { + ...s.pendingStartupByTabId, + [tabId]: { ...pendingStartup } + } + } + : {}) } } return {} diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index abb797878af..63248cd36c8 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -5,10 +5,48 @@ * test run so we don't litter the user's /tmp with test directories. */ -import { readFileSync, existsSync, rmSync, readdirSync } from 'node:fs' -import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { readFileSync, existsSync, realpathSync, rmSync } from 'node:fs' import { TEST_REPO_PATH_FILE } from './global-setup' +export function linkedWorktreePaths(testRepoDir: string): string[] { + const root = realpathSync(testRepoDir) + const output = execFileSync('git', ['-C', testRepoDir, 'worktree', 'list', '--porcelain'], { + encoding: 'utf8' + }) + const linked = new Set() + for (const line of output.split(/\r?\n/)) { + if (!line.startsWith('worktree ')) { + continue + } + const recordedPath = line.slice('worktree '.length) + if (!existsSync(recordedPath)) { + continue + } + const canonicalPath = realpathSync(recordedPath) + if (canonicalPath !== root) { + linked.add(canonicalPath) + } + } + return [...linked] +} + +export function cleanupTestRepository(testRepoDir: string): void { + const root = realpathSync(testRepoDir) + let worktreePaths: string[] = [] + try { + worktreePaths = linkedWorktreePaths(root) + } catch { + // The isolated repo is still safe to remove when Git metadata is unreadable. + } + for (const worktreeDir of worktreePaths) { + if (existsSync(worktreeDir)) { + rmSync(worktreeDir, { recursive: true, force: true }) + } + } + rmSync(root, { recursive: true, force: true }) +} + export default function globalTeardown(): void { if (!existsSync(TEST_REPO_PATH_FILE)) { return @@ -16,22 +54,7 @@ export default function globalTeardown(): void { const testRepoDir = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() if (testRepoDir && existsSync(testRepoDir)) { - // Why: git worktree add creates directories as siblings. Clean up any - // seeded or test-created worktrees in the same parent so reruns remain - // idempotent and do not leak temp repos into /tmp. - const parentDir = path.dirname(testRepoDir) - try { - const siblings = readdirSync(parentDir) - for (const name of siblings) { - if (name.startsWith('orca-e2e-worktree-') || name.startsWith('e2e-test-')) { - rmSync(path.join(parentDir, name), { recursive: true, force: true }) - } - } - } catch { - // Best-effort cleanup of worktrees - } - - rmSync(testRepoDir, { recursive: true, force: true }) + cleanupTestRepository(testRepoDir) console.error(`[e2e] Cleaned up test repo at ${testRepoDir}`) } diff --git a/tests/e2e/global-teardown.unit.test.ts b/tests/e2e/global-teardown.unit.test.ts new file mode 100644 index 00000000000..b5fabfe3de8 --- /dev/null +++ b/tests/e2e/global-teardown.unit.test.ts @@ -0,0 +1,52 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { cleanupTestRepository, linkedWorktreePaths } from './global-teardown' + +const roots: string[] = [] + +function git(cwd: string, args: string[]): void { + execFileSync('git', ['-C', cwd, ...args], { stdio: 'pipe' }) +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('E2E global teardown ownership', () => { + it('removes every linked run worktree and preserves unrelated siblings', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-teardown-contract-')) + roots.push(root) + const repoPath = path.join(root, 'orca-e2e-repo-run') + const firstWorktreePath = path.join(root, 'orca-e2e-worktree-owned') + const secondWorktreePath = path.join(root, 'e2e-test-owned') + const concurrentWorktreePath = path.join(root, 'orca-e2e-worktree-concurrent') + const unrelatedTestPath = path.join(root, 'e2e-test-unrelated') + mkdirSync(repoPath) + mkdirSync(concurrentWorktreePath) + mkdirSync(unrelatedTestPath) + writeFileSync(path.join(repoPath, 'README.md'), 'fixture\n') + git(repoPath, ['init']) + git(repoPath, ['config', 'user.email', 'e2e@test.local']) + git(repoPath, ['config', 'user.name', 'E2E Test']) + git(repoPath, ['add', 'README.md']) + git(repoPath, ['commit', '-m', 'seed']) + git(repoPath, ['worktree', 'add', '-b', 'first-owned', firstWorktreePath]) + git(repoPath, ['worktree', 'add', '-b', 'second-owned', secondWorktreePath]) + + expect(new Set(linkedWorktreePaths(repoPath))).toEqual( + new Set([realpathSync(firstWorktreePath), realpathSync(secondWorktreePath)]) + ) + cleanupTestRepository(repoPath) + + expect(existsSync(repoPath)).toBe(false) + expect(existsSync(firstWorktreePath)).toBe(false) + expect(existsSync(secondWorktreePath)).toBe(false) + expect(existsSync(concurrentWorktreePath)).toBe(true) + expect(existsSync(unrelatedTestPath)).toBe(true) + }) +}) diff --git a/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts b/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts new file mode 100644 index 00000000000..c734301d33f --- /dev/null +++ b/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts @@ -0,0 +1,349 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, readFileSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { stripAnsiEscapeSequences } from '../../src/shared/ansi-escape-sequences' +import { test, expect } from './helpers/orca-app' +import { + runNodeScriptInTerminal, + stageNodeScriptForTerminal +} from './helpers/run-node-script-in-terminal' +import { registerTerminalPaneMountReadiness } from './helpers/terminal-pane-mount-readiness' +import { + focusActiveTerminalInput, + getTerminalContent, + waitForPaneIdentitySnapshot, + waitForTerminalOutput +} from './helpers/terminal' + +type QuickCommandIdentity = { + marker: string + paneKey: string + pid: number + tabId: string +} + +type QueueObservation = { + pending: boolean + ptyIds: string[] +} + +type QueueObservationWindow = Window & { + __quickCommandQueueObservations?: QueueObservation[] + __stopQuickCommandQueueObservations?: () => void +} + +function exactMarkerLineCount(content: string, marker: string): number { + return stripAnsiEscapeSequences(content) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line === marker).length +} + +test.describe('Quick Command startup recovery', () => { + registerTerminalPaneMountReadiness() + + test('visible Quick Command survives a forced pre-bind recovery on one fresh PTY', async ({ + orcaPage + }) => { + const siblingBefore = await waitForPaneIdentitySnapshot(orcaPage, 1) + const siblingPtyId = siblingBefore.panes[0]?.ptyId + if (!siblingPtyId) { + throw new Error('Sibling terminal has no live PTY') + } + + const siblingMarker = `ORCA_QUICK_COMMAND_SIBLING_${randomUUID()}` + const siblingProbe = await runNodeScriptInTerminal( + orcaPage, + siblingPtyId, + `process.stdout.write(${JSON.stringify(`${siblingMarker}\n`)})` + ) + await waitForTerminalOutput(orcaPage, siblingMarker) + siblingProbe.cleanup() + + const marker = `ORCA_QUICK_COMMAND_RECOVERY_${randomUUID()}` + const label = `Recovery sentinel ${randomUUID()}` + const identityPath = path.join(os.tmpdir(), `orca-quick-command-identity-${randomUUID()}.json`) + const staged = stageNodeScriptForTerminal( + ` +const { writeFileSync } = require('node:fs') +const identity = { + marker: ${JSON.stringify(marker)}, + paneKey: process.env.ORCA_PANE_KEY || '', + pid: process.pid, + tabId: process.env.ORCA_TAB_ID || '' +} +writeFileSync(${JSON.stringify(identityPath)}, JSON.stringify(identity), { flag: 'wx' }) +process.stdout.write(${JSON.stringify(`${marker}\n`)}) +`.trim() + ) + + try { + await orcaPage.evaluate( + async ({ command, label }) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + await store.getState().updateSettings({ + terminalQuickCommands: [ + { + id: 'e2e-pre-bind-recovery', + label, + scope: { type: 'global' }, + action: 'terminal-command', + command, + appendEnter: true + } + ] + }) + const spawnBarrier = window.__terminalPtyPreSpawnE2EBarrier + if (!spawnBarrier) { + throw new Error('Terminal PTY pre-spawn E2E barrier unavailable') + } + spawnBarrier.arm() + }, + { command: staged.command, label } + ) + + const quickCommandButton = orcaPage.getByRole('button', { + name: `Run quick command: ${label}` + }) + await expect(quickCommandButton).toBeVisible() + await quickCommandButton.click() + await orcaPage.evaluate(async () => { + const spawnBarrier = window.__terminalPtyPreSpawnE2EBarrier + if (!spawnBarrier) { + throw new Error('Terminal PTY pre-spawn E2E barrier unavailable') + } + await spawnBarrier.waitUntilBlocked() + }) + + const blocked = await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + const state = store.getState() + const tabId = state.activeTabId + if (!tabId) { + throw new Error('Quick Command did not create an active tab') + } + return { + generation: + state.tabsByWorktree[state.activeWorktreeId ?? '']?.find((tab) => tab.id === tabId) + ?.generation ?? 0, + pending: state.pendingStartupByTabId[tabId]?.command ?? null, + status: window.__terminalPtyPreSpawnE2EBarrier?.status(), + tabId + } + }) + expect(blocked.pending).toBe(staged.command) + expect(blocked.status).toBe('blocked') + + await orcaPage.evaluate((tabId) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + const target = window as QueueObservationWindow + const observations: QueueObservation[] = [] + const observe = (): void => { + const state = store.getState() + observations.push({ + pending: state.pendingStartupByTabId[tabId] !== undefined, + ptyIds: [...(state.ptyIdsByTabId[tabId] ?? [])] + }) + } + target.__quickCommandQueueObservations = observations + target.__stopQuickCommandQueueObservations = store.subscribe(observe) + observe() + }, blocked.tabId) + + const remounted = await orcaPage.evaluate((tabId) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Renderer store unavailable') + } + return state.remountTerminalTabForRecovery(tabId) + }, blocked.tabId) + expect(remounted).toBe(true) + + // Keep the original pre-spawn attempt gated until React has committed the + // successor pane. Releasing earlier lets a loaded CI renderer finish the + // old mount's teardown before the replacement reaches its connect path. + await expect + .poll( + () => + orcaPage.evaluate( + ({ expectedGeneration, tabId }) => { + const state = window.__store?.getState() + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getPanes()[0] + const tab = state?.tabsByWorktree[state.activeWorktreeId ?? '']?.find( + (candidate) => candidate.id === tabId + ) + return { + generation: tab?.generation ?? 0, + leafReady: Boolean(pane?.leafId), + pending: state?.pendingStartupByTabId[tabId]?.command ?? null, + expectedGeneration + } + }, + { expectedGeneration: blocked.generation + 1, tabId: blocked.tabId } + ), + { message: 'Successor mount did not become ready before barrier release' } + ) + .toEqual({ + generation: blocked.generation + 1, + leafReady: true, + pending: staged.command, + expectedGeneration: blocked.generation + 1 + }) + await orcaPage.evaluate(() => window.__terminalPtyPreSpawnE2EBarrier?.release()) + + let targetPtyId = '' + let targetLeafId = '' + const successorReady = (): Promise<{ + generation: number + leafReady: boolean + pending: string | null + ptyReady: boolean + expectedGeneration: number + }> => + orcaPage.evaluate( + ({ expectedGeneration, tabId }) => { + const state = window.__store?.getState() + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getPanes()[0] + const tab = state?.tabsByWorktree[state.activeWorktreeId ?? '']?.find( + (candidate) => candidate.id === tabId + ) + return { + generation: tab?.generation ?? 0, + leafReady: Boolean(pane?.leafId), + pending: state?.pendingStartupByTabId[tabId]?.command ?? null, + ptyReady: Boolean(pane?.container.dataset.ptyId), + expectedGeneration + } + }, + { expectedGeneration: blocked.generation + 1, tabId: blocked.tabId } + ) + try { + await expect + .poll(successorReady, { + timeout: 60_000, + message: 'Successor mount did not bind its fresh Quick Command PTY' + }) + .toEqual({ + generation: blocked.generation + 1, + leafReady: true, + pending: null, + ptyReady: true, + expectedGeneration: blocked.generation + 1 + }) + } catch (error) { + const diagnostics = await orcaPage.evaluate(() => ({ + ptyConnect: (window as Window & { __ptyConnectDiag?: string[] }).__ptyConnectDiag ?? [], + barrier: window.__terminalPtyPreSpawnE2EBarrier?.status() ?? 'missing' + })) + throw new Error( + `${error instanceof Error ? error.message : String(error)}\nDiagnostics: ${JSON.stringify(diagnostics)}` + ) + } + + const successor = await orcaPage.evaluate((tabId) => { + const pane = window.__paneManagers?.get(tabId)?.getPanes()[0] + return { + leafId: pane?.leafId ?? '', + ptyId: pane?.container.dataset.ptyId ?? '' + } + }, blocked.tabId) + targetPtyId = successor.ptyId + targetLeafId = successor.leafId + expect(targetPtyId).not.toBe('') + expect(targetLeafId).not.toBe('') + expect(targetPtyId).not.toBe(siblingPtyId) + + const queueObservations = await orcaPage.evaluate(() => { + const target = window as QueueObservationWindow + target.__stopQuickCommandQueueObservations?.() + target.__stopQuickCommandQueueObservations = undefined + return target.__quickCommandQueueObservations ?? [] + }) + const firstConsumed = queueObservations.find((observation) => !observation.pending) + expect(firstConsumed?.ptyIds).toContain(targetPtyId) + + await expect + .poll( + () => + orcaPage.evaluate((tabId) => { + const pane = window.__paneManagers?.get(tabId)?.getPanes()[0] + return pane?.serializeAddon.serialize() ?? '' + }, blocked.tabId), + { message: 'Quick Command marker never reached the visible xterm' } + ) + .toContain(marker) + const targetContent = await orcaPage.evaluate((tabId) => { + const pane = window.__paneManagers?.get(tabId)?.getPanes()[0] + return pane?.serializeAddon.serialize() ?? '' + }, blocked.tabId) + expect(exactMarkerLineCount(targetContent, marker)).toBe(1) + + await expect.poll(() => existsSync(identityPath)).toBe(true) + const identity = JSON.parse(readFileSync(identityPath, 'utf8')) as QuickCommandIdentity + expect(identity).toMatchObject({ + marker, + paneKey: `${blocked.tabId}:${targetLeafId}`, + tabId: blocked.tabId + }) + expect(identity.pid).toBeGreaterThan(0) + + const ptyIdentity = await orcaPage.evaluate( + async ({ siblingPtyId, siblingTabId, tabId, targetPtyId }) => { + const state = window.__store?.getState() + const layout = state?.terminalLayoutsByTabId[tabId] + const sessions = await window.api.pty.listSessions() + return { + layoutPtyIds: Object.values(layout?.ptyIdsByLeafId ?? {}), + siblingLive: await window.api.pty.hasPty(siblingPtyId), + siblingStorePtyIds: state?.ptyIdsByTabId[siblingTabId] ?? [], + targetLive: await window.api.pty.hasPty(targetPtyId), + targetSessionIds: sessions + .filter((session) => session.id === targetPtyId) + .map((session) => session.id), + targetStorePtyIds: state?.ptyIdsByTabId[tabId] ?? [] + } + }, + { siblingPtyId, siblingTabId: siblingBefore.tabId, tabId: blocked.tabId, targetPtyId } + ) + expect(ptyIdentity.targetLive).toBe(true) + expect(ptyIdentity.targetSessionIds).toEqual([targetPtyId]) + expect(ptyIdentity.targetStorePtyIds).toContain(targetPtyId) + expect(ptyIdentity.layoutPtyIds).toContain(targetPtyId) + expect(ptyIdentity.siblingLive).toBe(true) + expect(ptyIdentity.siblingStorePtyIds).toContain(siblingPtyId) + + const siblingAfterMarker = `ORCA_QUICK_COMMAND_SIBLING_AFTER_${randomUUID()}` + await orcaPage.evaluate( + (tabId) => window.__store?.getState().setActiveTab(tabId), + siblingBefore.tabId + ) + await expect + .poll(() => orcaPage.evaluate(() => window.__store?.getState().activeTabId)) + .toBe(siblingBefore.tabId) + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.type(`echo ${siblingAfterMarker}`) + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await getTerminalContent(orcaPage)).split(siblingAfterMarker).length - 1) + .toBeGreaterThanOrEqual(1) + } finally { + await orcaPage + .evaluate(() => window.__terminalPtyPreSpawnE2EBarrier?.release()) + .catch(() => {}) + staged.cleanup() + rmSync(identityPath, { force: true }) + } + }) +}) diff --git a/tests/tools/windows-pty-native-capability-smoke/README.md b/tests/tools/windows-pty-native-capability-smoke/README.md new file mode 100644 index 00000000000..fa18f434687 --- /dev/null +++ b/tests/tools/windows-pty-native-capability-smoke/README.md @@ -0,0 +1,16 @@ +# Packaged Windows PTY native capability smoke + +This narrow harness runs the packaged `Orca.exe` with `ELECTRON_RUN_AS_NODE=1` and loads its bundled `node-pty`. Checkout Node processes provide the target/canary shell fixtures, so the smoke proves the packaged native addon without putting a GUI-subsystem Electron executable under ConPTY. A 256-bit fixture token binds the target shell, a grandchild that remains live after its intermediate launcher exits, and an unrelated canary to one unique named-pipe channel; the oracle checks the patched exports, job PIDs, exact job-handle termination, PTY/socket exit events, and canary survival. + +Run it against an unpacked Windows package: + +```text +pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe +``` + +The official `v1.4.158` package is the causal red artifact: it fails immediately because its +packaged `node-pty` lacks `assignCurrentProcessToJob`. Candidate green evidence comes from the +required Windows packaging job, which runs this smoke without retries. The one-shot probe flushes +its evidence and exits explicitly because direct native job termination intentionally bypasses +node-pty's ConPTY-worker disposal path; a 45-second outer ceiling reports bounded stdout, stderr, +and native-stage breadcrumbs if any boundary stops progressing. diff --git a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.mjs b/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.mjs new file mode 100644 index 00000000000..d311bf24f28 --- /dev/null +++ b/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.mjs @@ -0,0 +1,139 @@ +const REQUIRED_EXPORTS = ['assignCurrentProcessToJob', 'listJobProcessIds', 'terminateJob'] +const FIXTURE_TOKEN_PATTERN = /^[a-f0-9]{64}$/ + +function isFixtureObservation(value, expected) { + return ( + Number.isInteger(value?.pid) && + value.pid > 0 && + value.fixtureToken === expected.fixtureToken && + value.channel === expected.channel && + value.role === expected.role + ) +} + +function isFixtureClosure(value, expected) { + return ( + value?.fixtureToken === expected.fixtureToken && + value.channel === expected.channel && + value.role === expected.role + ) +} + +function isPtyExit(value, terminalHandle) { + return value?.terminalHandle === terminalHandle && Number.isInteger(value.exitCode) +} + +export function evaluatePackagedNodePtyCapability(evidence) { + const failures = [] + const exports = new Set(evidence?.patchedExports ?? []) + const fixtureToken = evidence?.fixtureToken + const channel = evidence?.channel + const target = evidence?.target + const canary = evidence?.canary + + if (REQUIRED_EXPORTS.some((name) => !exports.has(name))) { + failures.push('the packaged node-pty addon is missing a required patched export') + } + if (!FIXTURE_TOKEN_PATTERN.test(fixtureToken) || !channel?.includes(fixtureToken)) { + failures.push('the fixture channel is not bound to the per-run unguessable token') + } + if (!isFixtureObservation(target?.shell, { fixtureToken, channel, role: 'target-shell' })) { + failures.push('the target shell was not observed on its unique fixture channel') + } + if ( + !isFixtureObservation(target?.launcherExited, { + fixtureToken, + channel, + role: 'target-launcher-exited' + }) + ) { + failures.push('the transient launcher exit was not observed') + } + if ( + !isFixtureObservation(target?.grandchild, { + fixtureToken, + channel, + role: 'target-grandchild' + }) + ) { + failures.push('the grandchild was not observed after its launcher exited') + } + if (!isFixtureObservation(canary?.process, { fixtureToken, channel, role: 'canary-shell' })) { + failures.push('the unrelated canary was not observed on its unique fixture channel') + } + const observedPids = [ + target?.shell?.pid, + target?.launcherExited?.pid, + target?.grandchild?.pid, + canary?.process?.pid + ] + if (observedPids.every((pid) => Number.isInteger(pid)) && new Set(observedPids).size !== 4) { + failures.push('target shell, launcher, grandchild, and canary must be distinct processes') + } + + if (!Array.isArray(target?.jobProcessIds) || target.jobProcessIds.length === 0) { + failures.push('the target job membership must be available and nonempty') + } else if (!target.jobProcessIds.includes(target?.shell?.pid)) { + failures.push('the target job does not contain the observed shell PID') + } else if (!target.jobProcessIds.includes(target?.grandchild?.pid)) { + failures.push('the target job does not contain the observed grandchild PID') + } else if (target.jobProcessIds.includes(target?.launcherExited?.pid)) { + failures.push('the transient launcher is still live in the target job') + } + const close = evidence?.close + if ( + close?.method !== 'terminate-job' || + close?.requestedHandle !== target?.terminalHandle || + close?.completedHandle !== target?.terminalHandle + ) { + failures.push('close must complete for the one requested PTY job handle') + } + if ( + !isPtyExit(close?.targetExit, target?.terminalHandle) || + !isFixtureClosure(close?.targetShellClosed, { + fixtureToken, + channel, + role: 'target-shell' + }) || + !isFixtureClosure(close?.targetGrandchildClosed, { + fixtureToken, + channel, + role: 'target-grandchild' + }) + ) { + failures.push('target close did not complete from PTY and fixture socket exit events') + } + + if (canary?.connectedAfterTargetClose !== true) { + failures.push('the unrelated canary fixture connection was lost during target close') + } + if ( + !Array.isArray(canary?.jobProcessIdsAfterTargetClose) || + !canary.jobProcessIdsAfterTargetClose.includes(canary?.process?.pid) + ) { + failures.push('the unrelated canary PID is not live in its job after target close') + } + if (!isPtyExit(canary?.exit, canary?.terminalHandle)) { + failures.push('the unrelated canary PTY exit was not observed') + } + if ( + !isFixtureClosure(canary?.socketClosed, { + fixtureToken, + channel, + role: 'canary-shell' + }) + ) { + failures.push('the unrelated canary did not complete its later exact teardown') + } + + return { pass: failures.length === 0, failures } +} + +export function assertPackagedNodePtyCapability(evidence) { + const result = evaluatePackagedNodePtyCapability(evidence) + if (!result.pass) { + throw new Error( + `Packaged node-pty native capability failed:\n- ${result.failures.join('\n- ')}` + ) + } +} diff --git a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs b/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs new file mode 100644 index 00000000000..a752c6f499d --- /dev/null +++ b/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { evaluatePackagedNodePtyCapability } from './packaged-node-pty-capability-oracle.mjs' + +const fixtureToken = 'a'.repeat(64) +const channel = `\\\\.\\pipe\\orca-pty-native-capability-${fixtureToken}` + +function observation(pid, role) { + return { pid, fixtureToken, role, channel } +} + +function closure(role) { + return { fixtureToken, role, channel } +} + +function passingEvidence() { + const shell = observation(4100, 'target-shell') + const launcherExited = observation(4101, 'target-launcher-exited') + const grandchild = observation(4102, 'target-grandchild') + const canary = observation(5100, 'canary-shell') + return { + patchedExports: ['assignCurrentProcessToJob', 'listJobProcessIds', 'terminateJob'], + fixtureToken, + channel, + target: { + terminalHandle: 'pty-job:7:4100', + shell, + launcherExited, + grandchild, + jobProcessIds: [shell.pid, grandchild.pid] + }, + canary: { + terminalHandle: 'pty-job:8:5100', + process: canary, + connectedAfterTargetClose: true, + jobProcessIdsAfterTargetClose: [canary.pid], + exit: { terminalHandle: 'pty-job:8:5100', exitCode: 1, signal: 0 }, + socketClosed: closure('canary-shell') + }, + close: { + method: 'terminate-job', + requestedHandle: 'pty-job:7:4100', + completedHandle: 'pty-job:7:4100', + targetExit: { terminalHandle: 'pty-job:7:4100', exitCode: 1, signal: 0 }, + targetShellClosed: closure('target-shell'), + targetGrandchildClosed: closure('target-grandchild') + } + } +} + +describe('packaged node-pty native capability oracle', () => { + it('accepts exact target teardown while the unrelated canary remains live', () => { + expect(evaluatePackagedNodePtyCapability(passingEvidence())).toEqual({ + pass: true, + failures: [] + }) + }) + + it.each([ + ['missing patched export', (evidence) => evidence.patchedExports.pop()], + ['launcher exit absent', (evidence) => delete evidence.target.launcherExited], + ['empty job membership', (evidence) => (evidence.target.jobProcessIds = [])], + [ + 'target shell outside membership', + (evidence) => (evidence.target.jobProcessIds = [evidence.target.grandchild.pid]) + ], + [ + 'launcher-surviving grandchild outside membership', + (evidence) => (evidence.target.jobProcessIds = [evidence.target.shell.pid]) + ], + ['target exit absent', (evidence) => delete evidence.close.targetExit], + ['canary connection loss', (evidence) => (evidence.canary.connectedAfterTargetClose = false)] + ])('rejects %s', (_name, mutate) => { + const evidence = passingEvidence() + mutate(evidence) + + expect(evaluatePackagedNodePtyCapability(evidence).pass).toBe(false) + }) + + it.each([ + ['guessable token', (evidence) => (evidence.fixtureToken = 'run-1')], + [ + 'grandchild from another channel', + (evidence) => (evidence.target.grandchild.channel = '\\\\.\\pipe\\other') + ], + ['wrong completed handle', (evidence) => (evidence.close.completedHandle = 'pty-job:9:9999')], + [ + 'launcher still in target job', + (evidence) => evidence.target.jobProcessIds.push(evidence.target.launcherExited.pid) + ], + ['canary outside its job', (evidence) => (evidence.canary.jobProcessIdsAfterTargetClose = [])], + ['canary teardown absent', (evidence) => delete evidence.canary.socketClosed] + ])('rejects scope violation: %s', (_name, mutate) => { + const evidence = passingEvidence() + mutate(evidence) + + expect(evaluatePackagedNodePtyCapability(evidence).pass).toBe(false) + }) +}) diff --git a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs b/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs new file mode 100644 index 00000000000..580a2852a19 --- /dev/null +++ b/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs @@ -0,0 +1,448 @@ +const { randomBytes } = require('node:crypto') +const { writeSync } = require('node:fs') +const net = require('node:net') +const path = require('node:path') + +const EVIDENCE_PREFIX = 'ORCA_NODE_PTY_CAPABILITY_EVIDENCE=' +const EXPECTED_ROLES = new Set([ + 'target-shell', + 'target-launcher-exited', + 'target-grandchild', + 'canary-shell' +]) +const ONE_SHOT_MODES = new Set(['--exercise', '--exit-contract-fixture']) + +function isOneShotMode(mode) { + return ONE_SHOT_MODES.has(mode) +} + +function stage(name) { + writeSync(2, `[windows-pty-native-capability-smoke] stage=${name}\n`) +} + +function writeStream(stream, value = '') { + return new Promise((resolve, reject) => { + stream.write(value, (error) => (error ? reject(error) : resolve())) + }) +} + +async function exitOneShot(code, exit = process.exit) { + await Promise.all([writeStream(process.stdout), writeStream(process.stderr)]) + exit(code) +} + +function fixtureObservation(fixtureToken, role, channel, extra = {}) { + return { pid: process.pid, fixtureToken, role, channel, ...extra } +} + +function connectFixture(channel, fixtureToken, role, extra = {}) { + const socket = net.createConnection(channel) + socket.once('connect', () => { + socket.write(`${JSON.stringify(fixtureObservation(fixtureToken, role, channel, extra))}\n`) + }) + socket.on('error', (error) => { + process.stderr.write(`${error.stack || error.message}\n`) + process.exitCode = 1 + }) + return socket +} + +function reportFixtureObservation(channel, fixtureToken, role, extra = {}) { + return new Promise((resolve, reject) => { + const socket = net.createConnection(channel) + socket.once('connect', () => { + socket.end(`${JSON.stringify(fixtureObservation(fixtureToken, role, channel, extra))}\n`) + }) + socket.once('error', reject) + socket.once('close', resolve) + }) +} + +function buildGrandchildLaunch(channel, fixtureToken) { + return { + program: path.join(process.env.SystemRoot, 'System32', 'wscript.exe'), + args: [ + path.join(__dirname, 'real-orca-detached-launcher.vbs'), + process.execPath, + __filename, + '--grandchild-member', + channel, + fixtureToken, + 'target-grandchild' + ] + } +} + +function startGrandchildAfterLauncherExit(channel, fixtureToken, resourcesDir) { + const { spawnProcess } = require( + path.join(resourcesDir, 'app.asar.unpacked', 'out', 'shared', 'child-process', 'run-process.js') + ) + const launch = buildGrandchildLaunch(channel, fixtureToken) + const child = spawnProcess({ + ...launch, + env: process.env + }) + for (const stream of [child.stdin, child.stdout, child.stderr]) { + stream?.on('error', () => {}) + } + child.stdin?.end() + return new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code) => { + if (code !== 0) { + reject(new Error(`grandchild launcher exited ${code}`)) + return + } + reportFixtureObservation(channel, fixtureToken, 'target-launcher-exited', { + pid: child.pid + }).then(() => resolve(child.pid), reject) + }) + }) +} + +async function runPtyShell(channel, fixtureToken, role, resourcesDir) { + const socket = connectFixture(channel, fixtureToken, `${role}-shell`) + try { + if (role === 'target') { + await startGrandchildAfterLauncherExit(channel, fixtureToken, resourcesDir) + } + } catch (error) { + socket.destroy() + throw error + } +} + +function createFixtureServer(channel, fixtureToken) { + const pending = new Map() + const observations = new Map() + const sockets = new Map() + const closures = new Map() + const acceptedSockets = new Set() + let serverClosed = false + + function closureFor(role) { + const existing = closures.get(role) + if (existing) { + return existing + } + let resolve + const promise = new Promise((done) => { + resolve = done + }) + const closure = { promise, resolve } + closures.set(role, closure) + return closure + } + + function waitForRole(role) { + const existing = observations.get(role) + if (existing) { + return Promise.resolve(existing) + } + return new Promise((resolve) => pending.set(role, resolve)) + } + + const server = net.createServer((socket) => { + let input = '' + acceptedSockets.add(socket) + socket.once('close', () => acceptedSockets.delete(socket)) + socket.setEncoding('utf8') + socket.on('data', (chunk) => { + input += String(chunk) + const newline = input.indexOf('\n') + if (newline === -1) { + return + } + const observation = JSON.parse(input.slice(0, newline)) + if ( + observation.fixtureToken !== fixtureToken || + observation.channel !== channel || + !EXPECTED_ROLES.has(observation.role) + ) { + throw new Error('fixture observation did not match its unique token, channel, and role') + } + observations.set(observation.role, observation) + sockets.set(observation.role, socket) + pending.get(observation.role)?.(observation) + pending.delete(observation.role) + socket.once('close', () => { + closureFor(observation.role).resolve({ + fixtureToken, + channel, + role: observation.role + }) + }) + }) + }) + + const listening = new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(channel, resolve) + }) + const close = () => { + if (serverClosed) { + return Promise.resolve() + } + serverClosed = true + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + + return { + listening, + waitForRole, + waitForClose: (role) => closureFor(role).promise, + sockets, + destroySockets: () => { + for (const socket of acceptedSockets) { + socket.destroy() + } + server.closeAllConnections?.() + server.unref() + }, + close + } +} + +function terminalHandle(pty) { + return `pty-job:${pty._pty}:${pty.pid}` +} + +function exitEvent(pty) { + const handle = terminalHandle(pty) + return new Promise((resolve) => + pty.onExit((event) => resolve({ terminalHandle: handle, ...event })) + ) +} + +function waitForBarrier(promise, label, timeoutMs = 30_000) { + let timer + const deadline = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), + timeoutMs + ) + }) + return Promise.race([promise, deadline]).finally(() => clearTimeout(timer)) +} + +async function exercise(resourcesDir, fixtureExecutable) { + const nodePtyDir = path.join(resourcesDir, 'node_modules', 'node-pty') + stage('addon-load:start') + const nodePty = require(nodePtyDir) + const { module: native } = require(path.join(nodePtyDir, 'lib', 'utils.js')).loadNativeModule( + 'conpty' + ) + stage('addon-load:done') + const patchedExports = ['assignCurrentProcessToJob', 'listJobProcessIds', 'terminateJob'] + for (const name of patchedExports) { + if (typeof native[name] !== 'function') { + throw new Error(`packaged node-pty is missing ${name}`) + } + } + stage('host-job-assign:start') + const hostJobAssigned = native.assignCurrentProcessToJob() + stage('host-job-assign:done') + if (!hostJobAssigned) { + throw new Error('packaged probe could not establish host job ownership') + } + + const fixtureToken = randomBytes(32).toString('hex') + const channel = `\\\\.\\pipe\\orca-pty-native-capability-${fixtureToken}` + const fixtures = createFixtureServer(channel, fixtureToken) + stage('fixture-listen:start') + await waitForBarrier(fixtures.listening, 'fixture server listen') + stage('fixture-listen:done') + const options = { + name: 'xterm-256color', + cols: 80, + rows: 30, + cwd: process.cwd(), + env: process.env, + useConptyDll: true + } + const created = [] + const closed = new Set() + const exitPromises = [] + let completed = false + + try { + stage('target-spawn:start') + const target = nodePty.spawn( + fixtureExecutable, + [__filename, '--pty-shell', channel, fixtureToken, 'target', resourcesDir], + options + ) + stage('target-spawn:done') + created.push(target) + const targetExited = exitEvent(target) + exitPromises.push(targetExited) + stage('canary-spawn:start') + const canary = nodePty.spawn( + fixtureExecutable, + [__filename, '--pty-shell', channel, fixtureToken, 'canary', resourcesDir], + options + ) + stage('canary-spawn:done') + created.push(canary) + const canaryHandle = terminalHandle(canary) + const canaryExited = exitEvent(canary) + exitPromises.push(canaryExited) + + stage('fixture-readiness:start') + const [shell, launcherExited, grandchild, canaryProcess] = await Promise.all([ + waitForBarrier(fixtures.waitForRole('target-shell'), 'target shell readiness'), + waitForBarrier(fixtures.waitForRole('target-launcher-exited'), 'grandchild launcher exit'), + waitForBarrier(fixtures.waitForRole('target-grandchild'), 'target grandchild readiness'), + waitForBarrier(fixtures.waitForRole('canary-shell'), 'canary shell readiness') + ]) + stage('fixture-readiness:done') + stage('target-job-list:start') + const targetJobProcessIds = native.listJobProcessIds(target._pty, target.pid) + stage('target-job-list:done') + const targetHandle = terminalHandle(target) + stage('target-job-terminate:start') + const targetTerminated = native.terminateJob(target._pty, target.pid) + stage('target-job-terminate:done') + if (!targetTerminated) { + throw new Error('exact target job termination was refused') + } + closed.add(target) + stage('target-exit-barriers:start') + const [targetExit, targetShellClosed, targetGrandchildClosed] = await Promise.all([ + waitForBarrier(targetExited, 'target PTY exit'), + waitForBarrier(fixtures.waitForClose('target-shell'), 'target shell connection close'), + waitForBarrier( + fixtures.waitForClose('target-grandchild'), + 'target grandchild connection close' + ) + ]) + stage('target-exit-barriers:done') + + stage('canary-job-list:start') + const canaryJobProcessIdsAfterTargetClose = native.listJobProcessIds(canary._pty, canary.pid) + stage('canary-job-list:done') + const canarySocket = fixtures.sockets.get('canary-shell') + const connectedAfterTargetClose = Boolean(canarySocket && !canarySocket.destroyed) + stage('canary-job-terminate:start') + const canaryTerminated = native.terminateJob(canary._pty, canary.pid) + stage('canary-job-terminate:done') + if (!canaryTerminated) { + throw new Error('exact canary job termination was refused') + } + closed.add(canary) + stage('canary-exit-barriers:start') + const [canaryExit, canaryClosed] = await Promise.all([ + waitForBarrier(canaryExited, 'canary PTY exit'), + waitForBarrier(fixtures.waitForClose('canary-shell'), 'canary shell connection close') + ]) + stage('canary-exit-barriers:done') + + const evidence = { + patchedExports, + fixtureToken, + channel, + target: { + terminalHandle: targetHandle, + shell, + launcherExited, + grandchild, + jobProcessIds: targetJobProcessIds + }, + canary: { + terminalHandle: canaryHandle, + process: canaryProcess, + connectedAfterTargetClose, + jobProcessIdsAfterTargetClose: canaryJobProcessIdsAfterTargetClose, + exit: canaryExit, + socketClosed: canaryClosed + }, + close: { + method: 'terminate-job', + requestedHandle: targetHandle, + completedHandle: targetHandle, + targetExit, + targetShellClosed, + targetGrandchildClosed + } + } + stage('fixture-close:start') + await waitForBarrier(fixtures.close(), 'fixture server close') + stage('fixture-close:done') + await writeStream(process.stdout, `${EVIDENCE_PREFIX}${JSON.stringify(evidence)}\n`) + stage('evidence:flushed') + completed = true + } catch (error) { + process.stderr.write(`[windows-pty-native-capability-smoke] ${error.message}\n`) + throw error + } finally { + for (const pty of created) { + if (!closed.has(pty)) { + stage('cleanup-job-terminate:start') + native.terminateJob(pty._pty, pty.pid) + stage('cleanup-job-terminate:done') + } + } + if (!completed) { + await Promise.allSettled( + exitPromises.map((exit) => waitForBarrier(exit, 'cleanup PTY exit', 5_000)) + ) + fixtures.destroySockets() + try { + await waitForBarrier(fixtures.close(), 'fixture server cleanup', 5_000) + } catch (error) { + process.stderr.write(`[windows-pty-native-capability-smoke] ${error.message}\n`) + } + } else { + await fixtures.close() + } + } +} + +async function main() { + const [mode, ...args] = process.argv.slice(2) + if (mode === '--pty-shell') { + await runPtyShell(args[0], args[1], args[2], args[3]) + return + } + if (mode === '--grandchild-member') { + connectFixture(args[0], args[1], args[2]) + return + } + if (mode === '--exercise') { + if (!args[1]) { + throw new Error('exercise mode requires a fixture Node executable') + } + await exercise(args[0], args[1]) + return + } + if (mode === '--exit-contract-fixture') { + process.stdout.write('ORCA_ONE_SHOT_EVIDENCE=flushed\n') + setInterval(() => {}, 60_000) + return + } + throw new Error(`unknown packaged node-pty capability probe mode: ${mode}`) +} + +module.exports = { + buildGrandchildLaunch, + createFixtureServer, + isOneShotMode, + reportFixtureObservation +} + +if (require.main === module) { + const mode = process.argv[2] + main().then( + () => (isOneShotMode(mode) ? exitOneShot(0) : undefined), + async (error) => { + await writeStream(process.stderr, `${error.stack || error.message}\n`) + if (isOneShotMode(mode)) { + await exitOneShot(1) + } else { + process.exitCode = 1 + } + } + ) +} diff --git a/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs b/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs new file mode 100644 index 00000000000..f26b5ae481e --- /dev/null +++ b/tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs @@ -0,0 +1,89 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcess } from '../../../src/shared/child-process/run-process' + +const require = createRequire(import.meta.url) +const probePath = require.resolve('./packaged-node-pty-capability-probe.cjs') +const { + buildGrandchildLaunch, + createFixtureServer, + isOneShotMode, + reportFixtureObservation +} = require(probePath) +const originalSystemRoot = process.env.SystemRoot + +afterEach(() => { + if (originalSystemRoot === undefined) { + delete process.env.SystemRoot + } else { + process.env.SystemRoot = originalSystemRoot + } +}) + +describe('packaged node-pty launcher-surviving grandchild', () => { + it('flushes one-shot evidence and exits despite a referenced handle', async () => { + expect(isOneShotMode('--exercise')).toBe(true) + expect(isOneShotMode('--exit-contract-fixture')).toBe(true) + expect(isOneShotMode('--pty-shell')).toBe(false) + + const result = await runProcess({ + program: process.execPath, + args: [probePath, '--exit-contract-fixture'], + timeoutMs: 5_000 + }) + + expect(result).toMatchObject({ code: 0, timedOut: false }) + expect(result.stdout).toBe('ORCA_ONE_SHOT_EVIDENCE=flushed\n') + expect(result.stderr).toBe('') + }) + + it('uses the hidden WScript launcher without cmd or start/b', () => { + process.env.SystemRoot = 'C:\\Windows' + const channel = '\\\\.\\pipe\\fixture & literal' + + const launch = buildGrandchildLaunch(channel, 'fixture-token^literal') + + expect(launch.program).toMatch(/wscript\.exe$/i) + expect(launch.args).toEqual([ + expect.stringMatching(/real-orca-detached-launcher\.vbs$/), + process.execPath, + probePath, + '--grandchild-member', + channel, + 'fixture-token^literal', + 'target-grandchild' + ]) + expect(JSON.stringify(launch)).not.toMatch(/cmd\.exe|start "" \/b/i) + }) + + it('closes one-shot fixture observations before server teardown', async () => { + const fixtureDir = await mkdtemp(join(tmpdir(), 'orca-pty-capability-')) + const channel = join(fixtureDir, 'fixture.sock') + const fixtureToken = 'fixture-token' + const fixtures = createFixtureServer(channel, fixtureToken) + + try { + await fixtures.listening + const observationPromise = fixtures.waitForRole('target-launcher-exited') + + await reportFixtureObservation(channel, fixtureToken, 'target-launcher-exited', { + pid: 1234 + }) + + await expect(observationPromise).resolves.toEqual({ + pid: 1234, + fixtureToken, + role: 'target-launcher-exited', + channel + }) + await expect(fixtures.close()).resolves.toBeUndefined() + } finally { + fixtures.destroySockets() + await fixtures.close() + await rm(fixtureDir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/tools/windows-pty-native-capability-smoke/real-orca-detached-launcher.vbs b/tests/tools/windows-pty-native-capability-smoke/real-orca-detached-launcher.vbs new file mode 100644 index 00000000000..cc24018bd52 --- /dev/null +++ b/tests/tools/windows-pty-native-capability-smoke/real-orca-detached-launcher.vbs @@ -0,0 +1,24 @@ +Option Explicit + +Function QuoteArgument(value) + If InStr(value, Chr(34)) > 0 Or InStr(value, vbCr) > 0 Or InStr(value, vbLf) > 0 Then + Err.Raise vbObjectError + 1, "real-orca-detached-launcher", "Invalid argument" + End If + QuoteArgument = Chr(34) & value & Chr(34) +End Function + +Dim args, command, index, shell, status +Set args = WScript.Arguments +If args.Count = 0 Then + Err.Raise vbObjectError + 2, "real-orca-detached-launcher", "Missing executable" +End If + +command = "" +For index = 0 To args.Count - 1 + If Len(command) > 0 Then command = command & " " + command = command & QuoteArgument(CStr(args(index))) +Next + +Set shell = CreateObject("WScript.Shell") +status = shell.Run(command, 0, False) +If status <> 0 Then WScript.Quit status diff --git a/tests/tools/windows-pty-native-capability-smoke/run.mjs b/tests/tools/windows-pty-native-capability-smoke/run.mjs new file mode 100644 index 00000000000..491fba2642d --- /dev/null +++ b/tests/tools/windows-pty-native-capability-smoke/run.mjs @@ -0,0 +1,72 @@ +import { createRequire } from 'node:module' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { assertPackagedNodePtyCapability } from './packaged-node-pty-capability-oracle.mjs' + +const EVIDENCE_PREFIX = 'ORCA_NODE_PTY_CAPABILITY_EVIDENCE=' +const PROBE_TIMEOUT_MS = 45_000 +const MAX_DIAGNOSTIC_CHARS = 8_000 + +function executableArgument(argv) { + const value = argv.find((arg) => arg.startsWith('--exe='))?.slice('--exe='.length) + if (!value) { + throw new Error('usage: windows-pty-native-capability-smoke --exe=') + } + return path.resolve(value) +} + +export function checkoutRunProcessPath() { + return path.resolve(import.meta.dirname, '../../../out/shared/child-process/run-process.js') +} + +export function packagedProbeInvocation(executable, environment = process.env) { + const resolvedExecutable = path.resolve(executable) + const resourcesDir = path.join(path.dirname(resolvedExecutable), 'resources') + const probe = path.join(import.meta.dirname, 'packaged-node-pty-capability-probe.cjs') + return { + program: resolvedExecutable, + args: [probe, '--exercise', resourcesDir, process.execPath], + env: { ...environment, ELECTRON_RUN_AS_NODE: '1' }, + timeoutMs: PROBE_TIMEOUT_MS + } +} + +function diagnosticTail(value) { + return value.length <= MAX_DIAGNOSTIC_CHARS ? value : value.slice(-MAX_DIAGNOSTIC_CHARS) +} + +export function formatProbeFailure(result) { + return [ + `packaged native capability probe failed (code=${result.code}, timedOut=${result.timedOut})`, + `stdout:\n${diagnosticTail(result.stdout) || ''}`, + `stderr:\n${diagnosticTail(result.stderr) || ''}` + ].join('\n') +} + +function parseEvidence(stdout) { + const line = stdout.split(/\r?\n/).find((candidate) => candidate.startsWith(EVIDENCE_PREFIX)) + if (!line) { + throw new Error(`packaged probe did not emit ${EVIDENCE_PREFIX}`) + } + return JSON.parse(line.slice(EVIDENCE_PREFIX.length)) +} + +async function main() { + if (process.platform !== 'win32') { + throw new Error('windows-pty-native-capability-smoke requires a physical Windows host') + } + const executable = executableArgument(process.argv.slice(2)) + const require = createRequire(import.meta.url) + const { runProcess } = require(checkoutRunProcessPath()) + const result = await runProcess(packagedProbeInvocation(executable)) + if (result.code !== 0 || result.timedOut) { + throw new Error(formatProbeFailure(result)) + } + + assertPackagedNodePtyCapability(parseEvidence(result.stdout)) + process.stdout.write(`[windows-pty-native-capability-smoke] PASS ${executable}\n`) +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + await main() +} diff --git a/tests/tools/windows-pty-native-capability-smoke/run.test.mjs b/tests/tools/windows-pty-native-capability-smoke/run.test.mjs new file mode 100644 index 00000000000..bb8ab525da6 --- /dev/null +++ b/tests/tools/windows-pty-native-capability-smoke/run.test.mjs @@ -0,0 +1,47 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { checkoutRunProcessPath, formatProbeFailure, packagedProbeInvocation } from './run.mjs' + +const runnerSource = readFileSync(new URL('./run.mjs', import.meta.url), 'utf8') + +describe('packaged Windows native smoke runner boundary', () => { + it('uses one checkout-owned runner for current and affected package paths', () => { + const current = packagedProbeInvocation('/ci/current/dist/win-unpacked/Orca.exe') + const affected = packagedProbeInvocation('/ci/1.4.158/dist/win-unpacked/Orca.exe') + + expect(checkoutRunProcessPath()).toBe(path.resolve('out/shared/child-process/run-process.js')) + expect(current.program).not.toBe(affected.program) + expect(current.args[0]).toBe(affected.args[0]) + expect(current.args[1]).toBe('--exercise') + expect(current.args[2]).not.toBe(affected.args[2]) + expect(current.args[3]).toBe(process.execPath) + expect(affected.args[3]).toBe(process.execPath) + expect(current.env.ELECTRON_RUN_AS_NODE).toBe('1') + expect(affected.env.ELECTRON_RUN_AS_NODE).toBe('1') + expect(current.timeoutMs).toBe(affected.timeoutMs) + expect(current.timeoutMs).toBe(45_000) + }) + + it('keeps timeout evidence bounded and preserves both output channels', () => { + const failure = formatProbeFailure({ + code: null, + timedOut: true, + stdout: `old-${'x'.repeat(9_000)}-stdout-tail`, + stderr: 'stage=target-spawn:start' + }) + + expect(failure).toContain('code=null, timedOut=true') + expect(failure).not.toContain('old-') + expect(failure).toContain('stdout-tail') + expect(failure).toContain('stage=target-spawn:start') + }) + + it('does not import child_process or resolve the runner from the artifact', () => { + expect(runnerSource).not.toContain('node:child_process') + expect(runnerSource).not.toMatch( + /path\.join\(resourcesDir[\s\S]*?app\.asar\.unpacked[\s\S]*?run-process\.js/ + ) + expect(runnerSource).toContain("'../../../out/shared/child-process/run-process.js'") + }) +}) From 7b9529da22502390c8784852e8495215006f924d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:12:38 -0700 Subject: [PATCH 06/12] Add keyboard shortcut for workspace deletion (#16271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add keyboard shortcut for workspace deletion Default Mod+Shift+Backspace (⌘⇧⌫ on Mac) lets users delete the hovered worktree or folder workspace immediately. The shortcut targets the sidebar hover state rather than requiring focus, and avoids terminal pane D-based split shortcuts on all platforms. Co-authored-by: Brennan Benson * Omit delete shortcut from disabled Delete Worktree for primary checkout - Remove shortcut badge from the disabled "Delete Worktree" action when it cannot be executed - Only show shortcut in multi-context delete actions where the command is available - Extract host identity parsing into reusable helper function to prevent inline string manipulation - Fix folder workspace deletion to use correct host-qualified identity comparison * Document host extraction safety for destructive worktree ops Unqualified identities must stay undefined rather than defaulting to 'local'. Destructive operations depend on correct host identification. Added tests and JSDoc to clarify this safety-critical behavior. * fix test --------- Co-authored-by: Brennan Benson --- .../browser-guest-shortcut-dispatch.ts | 7 +- .../browser-guest-shortcut-forwarding.test.ts | 41 +++ ...ainWindow-terminal-focus-shortcuts.test.ts | 62 ++++- src/main/window/createMainWindow.ts | 5 +- ...-command-handlers-workspace-delete.test.ts | 88 +++++++ .../src/app-shell/app-command-handlers.ts | 20 ++ .../src/app-shell/use-global-keybindings.ts | 3 + ...rktreeContextMenu.delete-shortcut.test.tsx | 244 ++++++++++++++++++ .../sidebar/WorktreeContextMenu.tsx | 6 + .../WorktreeDeveloperMenuReveal.test.tsx | 3 +- .../sidebar/hovered-workspace-delete.test.ts | 223 ++++++++++++++++ .../sidebar/hovered-workspace-delete.ts | 133 ++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 16 +- .../src/lib/worktree-runtime-owner-index.ts | 6 +- ...r-workspace-owner-routed-mutations.test.ts | 34 +++ src/renderer/src/store/slices/repos.ts | 30 ++- src/shared/keybindings-parsing.test.ts | 2 + .../keybindings-unassigned-actions.test.ts | 25 +- src/shared/keybindings.ts | 10 +- src/shared/window-shortcut-policy.test.ts | 13 +- .../worktree/host-qualified-identity.test.ts | 26 ++ .../worktree/host-qualified-identity.ts | 18 ++ tests/e2e/worktree-delete-shortcut.spec.ts | 76 ++++++ 23 files changed, 1048 insertions(+), 43 deletions(-) create mode 100644 src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts create mode 100644 src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx create mode 100644 src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts create mode 100644 src/renderer/src/components/sidebar/hovered-workspace-delete.ts create mode 100644 tests/e2e/worktree-delete-shortcut.spec.ts diff --git a/src/main/browser/browser-guest-shortcut-dispatch.ts b/src/main/browser/browser-guest-shortcut-dispatch.ts index 8785bed2c11..3992a5ade35 100644 --- a/src/main/browser/browser-guest-shortcut-dispatch.ts +++ b/src/main/browser/browser-guest-shortcut-dispatch.ts @@ -46,7 +46,10 @@ export function forwardGuestShortcutInput( return true } if (input.isAutoRepeat) { - if (action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) { + if ( + (action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) || + action?.type === 'deleteCurrentWorkspace' + ) { event.preventDefault() return true } @@ -176,6 +179,8 @@ export function forwardGuestShortcutInput( renderer.send('ui:toggleQuickCommandsMenu') } else if (action?.type === 'openNewWorkspace') { renderer.send('ui:openNewWorkspace') + } else if (action?.type === 'deleteCurrentWorkspace') { + renderer.send('ui:deleteCurrentWorkspace') } else if (action?.type === 'openWorkspaceBoard') { renderer.send('ui:openWorkspaceBoard') } else if (action?.type === 'openTasks') { diff --git a/src/main/browser/browser-guest-shortcut-forwarding.test.ts b/src/main/browser/browser-guest-shortcut-forwarding.test.ts index 39a24fa45fc..a3385a08582 100644 --- a/src/main/browser/browser-guest-shortcut-forwarding.test.ts +++ b/src/main/browser/browser-guest-shortcut-forwarding.test.ts @@ -623,6 +623,47 @@ describe('setupGuestShortcutForwarding', () => { expect(rendererSendMock).toHaveBeenCalledWith('ui:toggleQuickCommandsMenu') }) + it('forwards workspace delete shortcuts from focused guest pages', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer() + }) + + const isMac = process.platform === 'darwin' + const preventDefault = triggerBeforeInput({ + code: 'Backspace', + key: 'Backspace', + meta: isMac, + control: !isMac, + shift: true + }) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(rendererSendMock).toHaveBeenCalledWith('ui:deleteCurrentWorkspace') + }) + + it('consumes repeated workspace delete shortcuts from focused guest pages', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer() + }) + + const isMac = process.platform === 'darwin' + const preventDefault = triggerBeforeInput({ + code: 'Backspace', + key: 'Backspace', + meta: isMac, + control: !isMac, + shift: true, + isAutoRepeat: true + }) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(rendererSendMock).not.toHaveBeenCalled() + }) + it('consumes guest zoom shortcuts even when the renderer is unavailable', () => { setupGuestShortcutForwarding({ browserTabId, diff --git a/src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts b/src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts index ba87f891737..9b88c60800c 100644 --- a/src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts +++ b/src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts @@ -341,6 +341,64 @@ describe('createMainWindow', () => { expect(webContents.send).not.toHaveBeenCalled() }) + it('suppresses auto-repeat workspace deletes from before-input-event', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null) + + const isDarwin = process.platform === 'darwin' + const input = { + type: 'keyDown', + code: 'Backspace', + key: 'Backspace', + meta: isDarwin, + control: !isDarwin, + alt: false, + shift: true + } + windowHandlers['before-input-event']({ preventDefault: vi.fn() } as never, input) + expect(webContents.send).toHaveBeenCalledWith('ui:deleteCurrentWorkspace') + + webContents.send.mockClear() + const repeatPreventDefault = vi.fn() + windowHandlers['before-input-event']({ preventDefault: repeatPreventDefault } as never, { + ...input, + isAutoRepeat: true + }) + + expect(repeatPreventDefault).toHaveBeenCalledOnce() + expect(webContents.send).not.toHaveBeenCalled() + }) + it('lets Terminal-first pass risky app shortcuts through when terminal input is focused', () => { const windowHandlers: Record void> = {} const webContents = { @@ -624,7 +682,7 @@ describe('createMainWindow', () => { expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:openQuickOpen') }) - it('forwards the configured workspace delete shortcut while terminal input is focused', () => { + it('forwards the default workspace delete shortcut while terminal input is focused', () => { const windowHandlers: Record void> = {} const webContents = { on: vi.fn((event, handler) => { @@ -662,7 +720,7 @@ describe('createMainWindow', () => { getSettings: () => ({ terminalShortcutPolicy: 'terminal-first' }) } as never, { - getKeybindings: () => ({ 'workspace.delete': ['Mod+Shift+Backspace'] }) + getKeybindings: () => ({}) } ) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 07e24b452b9..dac90bec357 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -811,7 +811,10 @@ export function createMainWindow( return true } - if (action.type === 'toggleQuickCommandsMenu' && isAutoRepeat) { + if ( + (action.type === 'toggleQuickCommandsMenu' || action.type === 'deleteCurrentWorkspace') && + isAutoRepeat + ) { event.preventDefault() return true } diff --git a/src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts b/src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts new file mode 100644 index 00000000000..7ffc3896a66 --- /dev/null +++ b/src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import type { HoveredWorkspaceDeleteTarget } from '../components/sidebar/hovered-workspace-delete' +import type { AppShortcutState, ShortcutDispatchInput } from './app-command-handlers' + +const mocks = vi.hoisted(() => ({ + deleteHoveredWorkspaceImmediately: vi.fn(), + hoveredTarget: { kind: 'worktree', worktree: {} } as HoveredWorkspaceDeleteTarget | null, + store: {} as AppState +})) + +vi.mock('../store', () => ({ + useAppStore: Object.assign(vi.fn(), { getState: () => mocks.store }) +})) + +vi.mock('../components/sidebar/hovered-workspace-delete', () => ({ + deleteHoveredWorkspaceImmediately: mocks.deleteHoveredWorkspaceImmediately, + resolveHoveredWorkspaceDeleteTarget: () => mocks.hoveredTarget +})) + +vi.mock('@/lib/floating-workspace-terminal-actions', () => ({ + isFloatingWorkspacePanelFocused: () => false +})) + +vi.mock('@/lib/terminal-shortcut-capture-notification', () => ({ + showTerminalShortcutCaptureNotification: vi.fn() +})) + +import { createAppCommandHandlers } from './app-command-handlers' + +function shortcutState(overrides: Partial = {}): AppShortcutState { + return { + activeView: 'terminal', + activeWorktreeId: 'repo::/feature', + actions: {} as AppShortcutState['actions'], + creationLayoutActive: false, + floatingTerminalEnabled: false, + floatingTerminalOpen: false, + floatingVisibleTabCount: 0, + keybindings: {}, + openFloatingWorkspaceMaximized: vi.fn(), + pluginCommands: [], + setFloatingTerminalOpen: vi.fn(), + terminalShortcutPolicy: 'orca-first', + workspaceChromeActive: true, + ...overrides + } +} + +function shortcutInput(): ShortcutDispatchInput { + return { + target: null, + defaultPrevented: false, + preventDefault: vi.fn() + } +} + +describe('workspace delete app command', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.store = { activeWorktreeId: 'repo::/feature' } as AppState + mocks.hoveredTarget = { kind: 'worktree', worktree: {} as never } + }) + + it('claims the chord and immediately deletes the active workspace', () => { + const input = shortcutInput() + const handler = createAppCommandHandlers(shortcutState(), input, 'terminal').get( + 'workspace.delete' + ) + + expect(handler?.()).toBe(true) + expect(input.preventDefault).toHaveBeenCalledOnce() + expect(mocks.deleteHoveredWorkspaceImmediately).toHaveBeenCalledWith( + mocks.store, + mocks.hoveredTarget + ) + }) + + it('does not claim the chord without a hovered workspace', () => { + const input = shortcutInput() + const handler = createAppCommandHandlers(shortcutState(), input).get('workspace.delete') + + mocks.hoveredTarget = null + expect(handler?.()).toBe(false) + expect(input.preventDefault).not.toHaveBeenCalled() + expect(mocks.deleteHoveredWorkspaceImmediately).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/app-shell/app-command-handlers.ts b/src/renderer/src/app-shell/app-command-handlers.ts index 11c69425552..99915597e67 100644 --- a/src/renderer/src/app-shell/app-command-handlers.ts +++ b/src/renderer/src/app-shell/app-command-handlers.ts @@ -5,6 +5,10 @@ import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to- import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls' import { TOGGLE_WORKSPACE_BOARD_EVENT } from '../components/sidebar/useWorkspaceBoardPanel' +import { + deleteHoveredWorkspaceImmediately, + resolveHoveredWorkspaceDeleteTarget +} from '../components/sidebar/hovered-workspace-delete' import { useAppStore } from '../store' import type { usePluginCommands } from '@/store/plugin-panels' import { isGitRepoKind } from '../../../shared/repo-kind' @@ -191,6 +195,22 @@ export function createAppCommandHandlers( }) } ], + [ + 'workspace.delete', + () => { + if (floatingWorkspaceFocused) { + return false + } + const store = useAppStore.getState() + const target = resolveHoveredWorkspaceDeleteTarget(store) + if (!target) { + return false + } + return claim('workspace.delete', () => { + deleteHoveredWorkspaceImmediately(store, target) + }) + } + ], [ 'workspace.openBoard', () => { diff --git a/src/renderer/src/app-shell/use-global-keybindings.ts b/src/renderer/src/app-shell/use-global-keybindings.ts index d40d928a20c..78051a58ed5 100644 --- a/src/renderer/src/app-shell/use-global-keybindings.ts +++ b/src/renderer/src/app-shell/use-global-keybindings.ts @@ -236,6 +236,9 @@ export function useGlobalKeybindings(args: { } const handlers = createAppCommandHandlers(state, input, context) + if (matchShortcut('workspace.delete') && handlers.get('workspace.delete')?.()) { + return + } for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) { if (matchShortcut(actionId) && handlers.get(actionId)?.()) { return diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx new file mode 100644 index 00000000000..c6664b14e2c --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx @@ -0,0 +1,244 @@ +/** + * @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 type { Worktree } from '../../../../shared/worktree/types' +import WorktreeContextMenu from './WorktreeContextMenu' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const shortcutLabelMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useOptionalShortcutLabel: shortcutLabelMock, + formatShortcutLabel: () => '', + formatOptionalShortcutLabel: () => null +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: function DropdownMenu(props: { children?: React.ReactNode; open?: boolean }) { + return
{props.children}
+ }, + DropdownMenuContent: function DropdownMenuContent(props: { children?: React.ReactNode }) { + return
{props.children}
+ }, + DropdownMenuItem: function DropdownMenuItem(props: { + children?: React.ReactNode + disabled?: boolean + variant?: string + }) { + return ( +
+ {props.children} +
+ ) + }, + DropdownMenuSeparator: function DropdownMenuSeparator() { + return
+ }, + DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: React.ReactNode }) { + return {props.children} + }, + DropdownMenuLabel: function DropdownMenuLabel(props: { children?: React.ReactNode }) { + return
{props.children}
+ }, + DropdownMenuSub: function DropdownMenuSub(props: { children?: React.ReactNode }) { + return
{props.children}
+ }, + DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: React.ReactNode }) { + return
{props.children}
+ }, + DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: React.ReactNode }) { + return
{props.children}
+ }, + DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: React.ReactNode }) { + return
{props.children}
+ }, + DropdownMenuRadioGroup: function DropdownMenuRadioGroup(props: { children?: React.ReactNode }) { + return
{props.children}
+ }, + DropdownMenuRadioItem: function DropdownMenuRadioItem(props: { children?: React.ReactNode }) { + return
{props.children}
+ } +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('lucide-react', async () => + (await import('../tab-bar/lucide-icon-stub-fixture')).stubEveryIcon() +) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +const defaultStoreState = { + updateWorktreeMeta: vi.fn(), + setWorktreesPinnedAndReveal: vi.fn(), + workspaceStatuses: [], + openModal: vi.fn(), + projectGroups: [], + createProjectGroup: vi.fn(), + moveProjectToGroup: vi.fn(), + deleteStateByWorktreeId: {}, + worktreeLineageById: {}, + workspaceLineageByChildKey: {}, + updateWorktreeLineage: vi.fn(), + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: {}, + worktreesByRepo: {}, + repos: [] +} + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof defaultStoreState) => unknown) => selector(defaultStoreState), + { + getState: () => defaultStoreState + } + ) +})) + +vi.mock('@/store/selectors', () => ({ + useAllWorktrees: () => [], + useRepoById: (repoId?: string) => + repoId ? { id: repoId, name: repoId, displayName: repoId } : undefined, + useRepoMap: () => new Map(), + useWorktreeMap: () => new Map() +})) + +vi.mock('./ProjectGroupNameDialog', () => ({ + ProjectGroupNameDialog: () => null +})) + +vi.mock('./WorktreeParentPickerPopover', () => ({ + WorktreeParentPickerPopover: () => null +})) + +vi.mock('./WorktreeDeveloperMenu', () => ({ + WorktreeDeveloperMenu: () => null +})) + +vi.mock('./WorktreeOpenInMenu', () => ({ + WorktreeOpenInSubMenu: () => null +})) + +vi.mock('./WorkspaceSleepMenuItems', () => ({ + WorkspaceSleepMenuItems: () => null +})) + +const mounted: { container: HTMLDivElement; root: Root }[] = [] + +function renderContextMenu(worktree: Worktree) { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render( + +
Card Content
+
+ ) + }) + mounted.push({ container, root }) + return container +} + +describe('WorktreeContextMenu delete shortcut display', () => { + beforeEach(() => { + shortcutLabelMock.mockImplementation((action: string) => { + if (action === 'workspace.delete') { + return '⌘⇧⌫' + } + return null + }) + }) + + afterEach(() => { + for (const { root, container } of mounted) { + act(() => { + root.unmount() + }) + container.remove() + } + mounted.length = 0 + }) + + it('renders the delete shortcut badge for standard worktree delete', () => { + const worktree = { + id: 'repo::wt-1', + repoId: 'repo', + name: 'wt-1', + path: '/path/to/wt-1', + isMainWorktree: false + } as unknown as Worktree + + const container = renderContextMenu(worktree) + const target = container.querySelector('[data-worktree-context-menu-scope]') as HTMLElement + expect(target).toBeTruthy() + act(() => { + target.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }) + ) + }) + + const shortcuts = container.querySelectorAll('[data-testid="dropdown-menu-shortcut"]') + const deleteShortcuts = Array.from(shortcuts).filter((el) => el.textContent === '⌘⇧⌫') + expect(deleteShortcuts.length).toBe(1) + }) + + it('omits the delete shortcut on disabled Delete Worktree for primary checkout', () => { + const worktree = { + id: 'repo-1::main', + repoId: 'repo-1', + name: 'main', + path: '/path/to/main', + isMainWorktree: true + } as unknown as Worktree + + const container = renderContextMenu(worktree) + const target = container.querySelector('[data-worktree-context-menu-scope]') as HTMLElement + act(() => { + target.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }) + ) + }) + + const shortcuts = container.querySelectorAll('[data-testid="dropdown-menu-shortcut"]') + const deleteShortcuts = Array.from(shortcuts).filter((el) => el.textContent === '⌘⇧⌫') + expect(deleteShortcuts.length).toBe(0) + }) + + it('omits the shortcut badge when the action is unassigned', () => { + shortcutLabelMock.mockReturnValue(null) + const worktree = { + id: 'repo::wt-1', + repoId: 'repo', + name: 'wt-1', + path: '/path/to/wt-1', + isMainWorktree: false + } as unknown as Worktree + + const container = renderContextMenu(worktree) + const target = container.querySelector('[data-worktree-context-menu-scope]') as HTMLElement + act(() => { + target.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }) + ) + }) + + const shortcuts = container.querySelectorAll('[data-testid="dropdown-menu-shortcut"]') + expect(shortcuts.length).toBe(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 787090a5cd3..8e3d87fd658 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -8,6 +8,7 @@ import { DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, @@ -68,6 +69,7 @@ import { translate } from '@/i18n/i18n' import { unnestWorktrees } from './worktree-unnest' import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' type Props = { worktree: Worktree @@ -337,6 +339,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const deleteState = useAppStore((s) => getDeleteStateForWorktreeHost(worktree, s.deleteStateByWorktreeId) ) + const deleteShortcut = useOptionalShortcutLabel('workspace.delete') const [menuOpen, setMenuOpen] = useState(false) // Why: the Developer submenu is a power-user affordance, so it is revealed by // holding Option/Alt at right-click — captured at open time (like the Help @@ -1086,6 +1089,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ 'auto.components.sidebar.WorktreeContextMenu.f4475537d8', 'Delete' )} + {!isMultiContext && !removesProject && deleteShortcut ? ( + {deleteShortcut} + ) : null} diff --git a/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx b/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx index 6955e14ac60..4eed6feea33 100644 --- a/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx @@ -66,7 +66,8 @@ vi.mock('@/components/ui/dropdown-menu', () => { DropdownMenuSub: passthrough, DropdownMenuSubContent: passthrough, DropdownMenuSubTrigger: passthrough, - DropdownMenuTrigger: passthrough + DropdownMenuTrigger: passthrough, + DropdownMenuShortcut: passthrough } }) diff --git a/src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts b/src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts new file mode 100644 index 00000000000..d0a749c0d14 --- /dev/null +++ b/src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + deleteHoveredWorkspaceImmediately, + getHoveredWorkspaceIdentity, + resolveHoveredWorkspaceDeleteTarget +} from './hovered-workspace-delete' + +function worktree(overrides: Partial = {}): Worktree { + return { + id: 'repo::/feature', + repoId: 'repo', + path: '/feature', + branch: 'feature', + isMainWorktree: false, + ...overrides + } as Worktree +} + +function hoveredDocument(...rows: { workspaceId: string; hostIdentity: string }[]) { + return { + activeElement: null, + querySelectorAll: () => ({ + length: rows.length, + item: (index: number) => { + const row = rows[index] + return row + ? ({ + dataset: { + worktreeId: row.workspaceId, + worktreeHostIdentity: row.hostIdentity + } + } as unknown as HTMLElement) + : null + } + }) + } as unknown as Pick +} + +function state(worktrees: Worktree[] = []): AppState { + return { + activeModal: 'none', + activeWorkspaceExecutionHostId: worktrees[0]?.hostId ?? null, + activeWorktreeId: worktrees[0]?.id ?? null, + deleteFolderWorkspace: vi.fn(), + deleteStateByWorktreeId: {}, + setActiveWorktree: vi.fn(), + worktreesByRepo: { repo: worktrees } + } as unknown as AppState +} + +describe('hovered workspace delete', () => { + it('uses the deepest hovered worktree row', () => { + expect( + getHoveredWorkspaceIdentity( + hoveredDocument( + { workspaceId: 'parent', hostIdentity: 'local|parent' }, + { workspaceId: 'child', hostIdentity: 'local|child' } + ) + ) + ).toEqual({ workspaceId: 'child', hostIdentity: 'local|child' }) + }) + + it('resolves the exact hovered host instead of the active workspace', () => { + const active = worktree({ id: 'repo::/active', path: '/active', hostId: 'local' }) + const hovered = worktree({ hostId: 'ssh:build', instanceId: 'instance-2' }) + + expect( + resolveHoveredWorkspaceDeleteTarget( + state([active, hovered]), + hoveredDocument({ workspaceId: hovered.id, hostIdentity: 'ssh:build|repo::/feature' }) + ) + ).toEqual({ kind: 'worktree', worktree: hovered }) + }) + + it('retains the hovered host when resolving a folder workspace', () => { + expect( + resolveHoveredWorkspaceDeleteTarget( + state(), + hoveredDocument({ + workspaceId: 'folder:folder-1', + hostIdentity: 'runtime:remote-1|folder:folder-1' + }) + ) + ).toEqual({ + kind: 'folder', + executionHostId: 'runtime:remote-1', + folderWorkspaceId: 'folder-1', + workspaceKey: 'folder:folder-1' + }) + }) + + it('rejects primary worktrees, stale rows, and missing hover', () => { + const primary = worktree({ hostId: 'local', isMainWorktree: true }) + const current = state([primary]) + + expect( + resolveHoveredWorkspaceDeleteTarget( + current, + hoveredDocument({ workspaceId: primary.id, hostIdentity: 'local|repo::/feature' }) + ) + ).toBeNull() + expect( + resolveHoveredWorkspaceDeleteTarget( + current, + hoveredDocument({ workspaceId: 'stale', hostIdentity: 'local|stale' }) + ) + ).toBeNull() + expect(resolveHoveredWorkspaceDeleteTarget(current, hoveredDocument())).toBeNull() + }) + + it('rejects worktrees that are already deleting', () => { + const target = worktree({ hostId: 'ssh:build' }) + const current = state([target]) + current.deleteStateByWorktreeId = { + 'ssh:build|repo::/feature': { + isDeleting: true, + error: null, + canForceDelete: false, + forceDeleteReason: null + } + } + + expect( + resolveHoveredWorkspaceDeleteTarget( + current, + hoveredDocument({ workspaceId: target.id, hostIdentity: 'ssh:build|repo::/feature' }) + ) + ).toBeNull() + }) + + it('rejects hovered rows while an editable control has focus', () => { + class EditableElement { + classList = { contains: () => false } + isContentEditable = false + closest = () => this + } + vi.stubGlobal('HTMLElement', EditableElement) + const target = worktree({ hostId: 'local' }) + const doc = hoveredDocument({ + workspaceId: target.id, + hostIdentity: 'local|repo::/feature' + }) as Pick + Object.defineProperty(doc, 'activeElement', { value: new EditableElement() }) + + try { + expect(resolveHoveredWorkspaceDeleteTarget(state([target]), doc)).toBeNull() + } finally { + vi.unstubAllGlobals() + } + }) + + it('routes the hovered worktree through the host-qualified safety flow', () => { + const target = worktree({ hostId: 'ssh:build', instanceId: 'instance-2' }) + const deleteWorktree = vi.fn() + const current = state([target]) + + expect( + deleteHoveredWorkspaceImmediately( + current, + { kind: 'worktree', worktree: target }, + { + deleteWorktree, + getCurrentState: () => current + } + ) + ).toBe(true) + expect(deleteWorktree).toHaveBeenCalledWith(target.id, { + expectedHostId: 'ssh:build', + expectedInstanceId: 'instance-2' + }) + }) + + it('removes a hovered folder workspace from Orca without deleting its directory', async () => { + const current = state() + current.activeWorktreeId = 'folder:folder-1' + current.activeWorkspaceExecutionHostId = 'runtime:remote-1' + current.deleteFolderWorkspace = vi.fn().mockResolvedValue(true) + + expect( + deleteHoveredWorkspaceImmediately( + current, + { + kind: 'folder', + executionHostId: 'runtime:remote-1', + folderWorkspaceId: 'folder-1', + workspaceKey: 'folder:folder-1' + }, + { deleteWorktree: vi.fn(), getCurrentState: () => current } + ) + ).toBe(true) + await vi.waitFor(() => expect(current.setActiveWorktree).toHaveBeenCalledWith(null)) + expect(current.deleteFolderWorkspace).toHaveBeenCalledWith('folder-1', { + executionHostId: 'runtime:remote-1' + }) + }) + + it('rejects a duplicate folder delete while the first request is pending', async () => { + let finishDelete!: (deleted: boolean) => void + const current = state() + current.deleteFolderWorkspace = vi.fn( + () => new Promise((resolve) => (finishDelete = resolve)) + ) + const target = { + kind: 'folder' as const, + executionHostId: 'runtime:remote-2' as const, + folderWorkspaceId: 'folder-2', + workspaceKey: 'folder:folder-2' + } + const dependencies = { deleteWorktree: vi.fn(), getCurrentState: () => current } + + expect(deleteHoveredWorkspaceImmediately(current, target, dependencies)).toBe(true) + expect(deleteHoveredWorkspaceImmediately(current, target, dependencies)).toBe(false) + expect(current.deleteFolderWorkspace).toHaveBeenCalledOnce() + + finishDelete(false) + await vi.waitFor(() => + expect(deleteHoveredWorkspaceImmediately(current, target, dependencies)).toBe(true) + ) + finishDelete(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/hovered-workspace-delete.ts b/src/renderer/src/components/sidebar/hovered-workspace-delete.ts new file mode 100644 index 00000000000..f1ecdc30d8b --- /dev/null +++ b/src/renderer/src/components/sidebar/hovered-workspace-delete.ts @@ -0,0 +1,133 @@ +import { useAppStore } from '@/store' +import { getAllWorktreesFromState } from '@/store/selectors' +import type { AppState } from '@/store/types' +import { isEditableTarget } from '@/lib/editable-target' +import { + composeWorktreeHostIdentity, + getExecutionHostIdFromWorktreeHostIdentity, + getWorktreeHostIdentity +} from '../../../../shared/worktree/host-qualified-identity' +import type { Worktree } from '../../../../shared/worktree/types' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { runWorktreeDelete } from './delete-worktree-flow' +import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' + +const pendingFolderDeletes = new Set() + +type DeleteWorktree = typeof runWorktreeDelete +type HoveredWorkspaceDeleteState = Pick< + AppState, + | 'activeModal' + | 'activeWorktreeId' + | 'deleteFolderWorkspace' + | 'deleteStateByWorktreeId' + | 'worktreesByRepo' +> +type CurrentWorkspaceState = Pick< + AppState, + 'activeWorkspaceExecutionHostId' | 'activeWorktreeId' | 'setActiveWorktree' +> +type HoveredWorkspaceDeleteDependencies = { + deleteWorktree: DeleteWorktree + getCurrentState: () => CurrentWorkspaceState +} +type HoveredWorkspaceDocument = Pick + +export type HoveredWorkspaceDeleteTarget = + | { + kind: 'folder' + executionHostId: ExecutionHostId + folderWorkspaceId: string + workspaceKey: string + } + | { kind: 'worktree'; worktree: Worktree } + +export function getHoveredWorkspaceIdentity( + doc: HoveredWorkspaceDocument = document +): { hostIdentity: string; workspaceId: string } | null { + const hoveredRows = doc.querySelectorAll( + '[data-worktree-sidebar] [role="option"][data-worktree-id]:hover' + ) + const row = hoveredRows.item(hoveredRows.length - 1) + const workspaceId = row?.dataset.worktreeId + const hostIdentity = row?.dataset.worktreeHostIdentity + return workspaceId && hostIdentity ? { workspaceId, hostIdentity } : null +} + +export function resolveHoveredWorkspaceDeleteTarget( + state: HoveredWorkspaceDeleteState, + doc: HoveredWorkspaceDocument = document +): HoveredWorkspaceDeleteTarget | null { + if (state.activeModal !== 'none' || (doc.activeElement && isEditableTarget(doc.activeElement))) { + return null + } + const hovered = getHoveredWorkspaceIdentity(doc) + if (!hovered) { + return null + } + const workspaceScope = parseWorkspaceKey(hovered.workspaceId) + if (workspaceScope?.type === 'folder') { + const executionHostId = getExecutionHostIdFromWorktreeHostIdentity(hovered.hostIdentity) + if (!executionHostId) { + return null + } + return { + kind: 'folder', + executionHostId, + folderWorkspaceId: workspaceScope.folderWorkspaceId, + workspaceKey: hovered.workspaceId + } + } + const worktree = getAllWorktreesFromState(state).find( + (candidate) => + candidate.id === hovered.workspaceId && + getWorktreeHostIdentity(candidate) === hovered.hostIdentity + ) + return worktree && + !worktree.isMainWorktree && + !getDeleteStateForWorktreeHost(worktree, state.deleteStateByWorktreeId)?.isDeleting + ? { kind: 'worktree', worktree } + : null +} + +export function deleteHoveredWorkspaceImmediately( + state: HoveredWorkspaceDeleteState, + target: HoveredWorkspaceDeleteTarget | null = resolveHoveredWorkspaceDeleteTarget(state), + dependencies: HoveredWorkspaceDeleteDependencies = { + deleteWorktree: runWorktreeDelete, + getCurrentState: useAppStore.getState + } +): boolean { + if (!target) { + return false + } + if (target.kind === 'folder') { + const pendingIdentity = composeWorktreeHostIdentity(target.executionHostId, target.workspaceKey) + if (pendingFolderDeletes.has(pendingIdentity)) { + return false + } + pendingFolderDeletes.add(pendingIdentity) + void state + .deleteFolderWorkspace(target.folderWorkspaceId, { + executionHostId: target.executionHostId + }) + .then((deleted) => { + const current = dependencies.getCurrentState() + if ( + deleted && + current.activeWorktreeId === target.workspaceKey && + current.activeWorkspaceExecutionHostId === target.executionHostId + ) { + current.setActiveWorktree(null) + } + }) + .finally(() => pendingFolderDeletes.delete(pendingIdentity)) + return true + } + dependencies.deleteWorktree(target.worktree.id, { + expectedInstanceId: target.worktree.instanceId, + ...(target.worktree.hostId ? { expectedHostId: target.worktree.hostId } : {}) + }) + return true +} diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 333f5b51a69..f4700df44b3 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -8,7 +8,7 @@ import { getWorktreeMapFromState, getRepoMapFromState } from '@/store/selectors' import { applyUIZoom } from '@/lib/ui-zoom' import { activateAndRevealWorktree, activateAndRevealWorkspace } from '@/lib/worktree-activation' import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' -import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow' +import { deleteHoveredWorkspaceImmediately } from '@/components/sidebar/hovered-workspace-delete' import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background' import { TOGGLE_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' @@ -1426,20 +1426,10 @@ export function useIpcEvents(): void { if (window.api.ui.onDeleteCurrentWorkspace) { unsubs.push( window.api.ui.onDeleteCurrentWorkspace(() => { - const store = useAppStore.getState() - if ( - store.activeModal !== 'none' || - store.activeView !== 'terminal' || - !store.activeWorktreeId - ) { + if (isFloatingWorkspacePanelFocused()) { return } - runWorktreeDelete( - store.activeWorktreeId, - store.activeWorkspaceExecutionHostId - ? { expectedHostId: store.activeWorkspaceExecutionHostId } - : {} - ) + deleteHoveredWorkspaceImmediately(useAppStore.getState()) }) ) } diff --git a/src/renderer/src/lib/worktree-runtime-owner-index.ts b/src/renderer/src/lib/worktree-runtime-owner-index.ts index 047976b4587..7bd0ebf2eba 100644 --- a/src/renderer/src/lib/worktree-runtime-owner-index.ts +++ b/src/renderer/src/lib/worktree-runtime-owner-index.ts @@ -54,7 +54,7 @@ type IndexedProjectGroupOwnerResolution = | { kind: 'missing' } | { kind: 'ambiguous' } -function catalogOwnerHostId(owner: { +export function getCatalogOwnerHostId(owner: { connectionId?: string | null executionHostId?: string | null }): ExecutionHostId { @@ -74,11 +74,11 @@ function buildCatalogOwnerIndex< const next = new Map() for (const record of records) { const id = record.id - const hostId = catalogOwnerHostId(record) + const hostId = getCatalogOwnerHostId(record) const current = next.get(id) if (!current) { next.set(id, { kind: 'resolved', owner: record }) - } else if (current.kind === 'resolved' && catalogOwnerHostId(current.owner) !== hostId) { + } else if (current.kind === 'resolved' && getCatalogOwnerHostId(current.owner) !== hostId) { next.set(id, { kind: 'ambiguous' }) } next.set(`${id}\0${hostId}`, { diff --git a/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts b/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts index 23e8a860706..b0298e76732 100644 --- a/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts +++ b/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts @@ -427,5 +427,39 @@ describe('folder workspace owner-routed mutations', () => { timeoutMs: 15_000 }) expect(folderWorkspacesDelete).not.toHaveBeenCalled() + expect(store.getState().folderWorkspaces).toEqual([]) + }) + + it('deletes only the host-qualified folder when ids collide', async () => { + const localFolder = makeFolderWorkspace({ executionHostId: 'local' }) + const runtimeFolder = makeFolderWorkspace({ executionHostId: 'runtime:env-owner' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-delete-folder', + ok: true, + result: { deleted: true }, + _meta: { runtimeId: 'runtime-owner' } + }) + const store = createTestStore() + store.setState({ + activeWorktreeId: `folder:${runtimeFolder.id}`, + activeWorkspaceExecutionHostId: 'runtime:env-owner', + settings: { activeRuntimeEnvironmentId: 'env-owner' } as never, + projectGroups: [{ ...projectGroup, executionHostId: 'local' }], + folderWorkspaces: [localFolder, runtimeFolder] + }) + + await expect( + store + .getState() + .deleteFolderWorkspace(runtimeFolder.id, { executionHostId: 'runtime:env-owner' }) + ).resolves.toBe(true) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-owner', + method: 'folderWorkspace.delete', + params: { folderWorkspaceId: runtimeFolder.id }, + timeoutMs: 15_000 + }) + expect(store.getState().folderWorkspaces).toEqual([localFolder]) }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index f0fd6fcbae8..91cb7655790 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -112,6 +112,7 @@ import { getEnvironmentSshStateGeneration } from './runtime-environment-ssh' import { getRuntimeEnvironmentConnectionGeneration } from './runtime-status' import { findFolderWorkspaceOwner, + getExecutionHostIdForFolderWorkspace, getRuntimeEnvironmentIdForFolderWorkspace } from '@/lib/folder-workspace-runtime-owner' import { @@ -1908,7 +1909,10 @@ export type RepoSlice = { updates: FolderWorkspaceUpdates, options?: { executionHostId?: ExecutionHostId } ) => Promise - deleteFolderWorkspace: (folderWorkspaceId: string) => Promise + deleteFolderWorkspace: ( + folderWorkspaceId: string, + options?: { executionHostId?: ExecutionHostId } + ) => Promise // options.hostId targets a specific host's row + RPC target when the id exists on multiple hosts; else the group's own host owns the call. updateProjectGroup: ( groupId: string, @@ -2980,12 +2984,22 @@ export const createRepoSlice: StateCreator = (set, } }, - deleteFolderWorkspace: async (folderWorkspaceId) => { + deleteFolderWorkspace: async (folderWorkspaceId, options) => { const state = get() - if (!findFolderWorkspaceOwner(state, folderWorkspaceId)) { + const executionHostId = options?.executionHostId + if (!findFolderWorkspaceOwner(state, folderWorkspaceId, executionHostId)) { return false } - const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace(state, folderWorkspaceId) + const ownerHostId = getExecutionHostIdForFolderWorkspace( + state, + folderWorkspaceId, + executionHostId + ) + const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace( + state, + folderWorkspaceId, + executionHostId + ) try { // Why: deletion targets the folder's owner; focus may be on a different host. const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) @@ -3006,11 +3020,15 @@ export const createRepoSlice: StateCreator = (set, const workspaceKey = folderWorkspaceKey(folderWorkspaceId) set((s) => ({ folderWorkspaces: s.folderWorkspaces.filter( - (workspace) => workspace.id !== folderWorkspaceId + (workspace) => + workspace.id !== folderWorkspaceId || + getFolderWorkspaceHostId(workspace, s.projectGroups) !== ownerHostId ), folderWorkspacePathStatuses: {} })) - get().purgeWorktreeTerminalState([workspaceKey]) + if (!get().folderWorkspaces.some((workspace) => workspace.id === folderWorkspaceId)) { + get().purgeWorktreeTerminalState([workspaceKey]) + } return true } catch (err) { console.error('Failed to delete folder workspace:', err) diff --git a/src/shared/keybindings-parsing.test.ts b/src/shared/keybindings-parsing.test.ts index 32184076085..4b9dd99d658 100644 --- a/src/shared/keybindings-parsing.test.ts +++ b/src/shared/keybindings-parsing.test.ts @@ -144,6 +144,8 @@ describe('keybindings', () => { it('formats keybindings with platform labels', () => { expect(formatKeybindingList(['Mod+Shift+J'], 'darwin')).toBe('⌘⇧J') expect(formatKeybindingList(['Mod+Shift+J'], 'linux')).toBe('Ctrl+Shift+J') + expect(formatKeybindingList(['Mod+Shift+Backspace'], 'darwin')).toBe('⌘⇧⌫') + expect(formatKeybindingList(['Mod+Shift+Backspace'], 'linux')).toBe('Ctrl+Shift+Backspace') expect(formatKeybindingList([], 'win32')).toBe('Unassigned') }) diff --git a/src/shared/keybindings-unassigned-actions.test.ts b/src/shared/keybindings-unassigned-actions.test.ts index cb135f36bf3..007f1074bca 100644 --- a/src/shared/keybindings-unassigned-actions.test.ts +++ b/src/shared/keybindings-unassigned-actions.test.ts @@ -56,7 +56,7 @@ describe('keybindings', () => { ).toBe(true) }) - it('keeps workspace delete unassigned until users customize it', () => { + it('binds immediate workspace delete without claiming terminal split right', () => { const binding = { key: 'Backspace', code: 'Backspace', @@ -66,13 +66,28 @@ describe('keybindings', () => { shift: true } - expect(getEffectiveKeybindingsForAction('workspace.delete', 'linux')).toEqual([]) - expect(keybindingMatchesAction('workspace.delete', binding, 'linux')).toBe(false) + expect(getEffectiveKeybindingsForAction('workspace.delete', 'darwin')).toEqual([ + 'Mod+Shift+Backspace' + ]) + expect(getEffectiveKeybindingsForAction('workspace.delete', 'linux')).toEqual([ + 'Mod+Shift+Backspace' + ]) + expect(getEffectiveKeybindingsForAction('workspace.delete', 'win32')).toEqual([ + 'Mod+Shift+Backspace' + ]) + expect(keybindingMatchesAction('workspace.delete', binding, 'linux')).toBe(true) expect( keybindingMatchesAction('workspace.delete', binding, 'linux', { - 'workspace.delete': ['Mod+Shift+Backspace'] + 'workspace.delete': [] }) - ).toBe(true) + ).toBe(false) + expect( + keybindingMatchesAction( + 'workspace.delete', + { key: 'd', code: 'KeyD', control: false, meta: true, alt: true, shift: false }, + 'darwin' + ) + ).toBe(false) }) it('keeps workspace board unassigned until users customize it', () => { diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 0beb3fa0faa..17f48be0159 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -293,8 +293,8 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ 'remove', 'trash' ], - // Why: ship now without a default chord; user overrides still win when a future default is assigned. - defaultBindings: platformBindings([]), + // Why: Backspace avoids the terminal pane's D-based split shortcuts on every platform. + defaultBindings: platformBindings(['Mod+Shift+Backspace']), allowInTerminal: true }, { @@ -2281,7 +2281,7 @@ export function formatKeybinding(binding: string, platform: NodeJS.Platform): st if (parsed.shift) { parts.push(isMac ? '⇧' : 'Shift') } - parts.push(formatKeyToken(parsed.key)) + parts.push(formatKeyToken(parsed.key, isMac)) return parts } @@ -2317,7 +2317,7 @@ export function findKeybindingActionsForBinding( ).map((definition) => definition.id) } -function formatKeyToken(token: string): string { +function formatKeyToken(token: string, isMac: boolean): string { const labels: Record = { BracketLeft: '[', BracketRight: ']', @@ -2341,7 +2341,7 @@ function formatKeyToken(token: string): string { Quote: "'", Backquote: '`', Enter: 'Enter', - Backspace: 'Backspace', + Backspace: isMac ? '⌫' : 'Backspace', Delete: 'Delete', Insert: 'Insert', Tab: 'Tab', diff --git a/src/shared/window-shortcut-policy.test.ts b/src/shared/window-shortcut-policy.test.ts index 25e66e80366..77bc37a5cf9 100644 --- a/src/shared/window-shortcut-policy.test.ts +++ b/src/shared/window-shortcut-policy.test.ts @@ -377,7 +377,7 @@ describe('resolveWindowShortcutAction', () => { ).toEqual({ type: 'openTasks' }) }) - it('leaves workspace delete unbound by default but honors custom terminal-active bindings', () => { + it('resolves workspace delete by default and honors custom terminal-active bindings', () => { const input = { code: 'Backspace', key: 'Backspace', @@ -387,17 +387,18 @@ describe('resolveWindowShortcutAction', () => { shift: true } - expect(resolveWindowShortcutAction(input, 'linux')).toBeNull() + expect(resolveWindowShortcutAction(input, 'linux')).toEqual({ type: 'deleteCurrentWorkspace' }) + const customInput = { ...input, code: 'KeyX', key: 'x', alt: true, shift: false } expect( - resolveWindowShortcutAction(input, 'linux', { - 'workspace.delete': ['Mod+Shift+Backspace'] + resolveWindowShortcutAction(customInput, 'linux', { + 'workspace.delete': ['Mod+Alt+X'] }) ).toEqual({ type: 'deleteCurrentWorkspace' }) expect( resolveWindowShortcutAction( - input, + customInput, 'linux', - { 'workspace.delete': ['Mod+Shift+Backspace'] }, + { 'workspace.delete': ['Mod+Alt+X'] }, { context: 'terminal', terminalShortcutPolicy: 'terminal-first' } ) ).toEqual({ type: 'deleteCurrentWorkspace' }) diff --git a/src/shared/worktree/host-qualified-identity.test.ts b/src/shared/worktree/host-qualified-identity.test.ts index e0495aa19be..f6237005b28 100644 --- a/src/shared/worktree/host-qualified-identity.test.ts +++ b/src/shared/worktree/host-qualified-identity.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../execution-host' import { composeWorktreeHostIdentity, + getExecutionHostIdFromWorktreeHostIdentity, getWorktreeHostIdentity, getWorktreeIdFromHostIdentity } from './host-qualified-identity' @@ -58,4 +59,29 @@ describe('worktree host identity', () => { expect(toSshExecutionHostId('we|rd')).not.toContain('|') expect(toRuntimeExecutionHostId('we|rd')).not.toContain('|') }) + + it('rejects identities without a host separator', () => { + expect(getExecutionHostIdFromWorktreeHostIdentity('ssh:build-box')).toBeUndefined() + }) + + // An unqualified row must not be assumed local: a destructive action keyed off + // this would then run against the wrong host. + it('leaves an unqualified identity without a host', () => { + expect( + getExecutionHostIdFromWorktreeHostIdentity( + composeWorktreeHostIdentity(undefined, WORKTREE_ID) + ) + ).toBeUndefined() + }) + + it('recovers the host from a qualified identity', () => { + expect( + getExecutionHostIdFromWorktreeHostIdentity(composeWorktreeHostIdentity('local', WORKTREE_ID)) + ).toBe('local') + expect( + getExecutionHostIdFromWorktreeHostIdentity( + composeWorktreeHostIdentity(toSshExecutionHostId('build-box'), WORKTREE_ID) + ) + ).toBe(toSshExecutionHostId('build-box')) + }) }) diff --git a/src/shared/worktree/host-qualified-identity.ts b/src/shared/worktree/host-qualified-identity.ts index 7e144c6336d..dcdc4411724 100644 --- a/src/shared/worktree/host-qualified-identity.ts +++ b/src/shared/worktree/host-qualified-identity.ts @@ -1,4 +1,5 @@ import type { ExecutionHostId } from '../execution-host' +import { parseExecutionHostId } from '../execution-host' import type { Worktree } from './types' /** @@ -38,6 +39,23 @@ export function composeWorktreeHostIdentity( return `${hostId ?? ''}${HOST_SEPARATOR}${worktreeId}` } +/** + * The host back out of an identity, when the identity names one. + * + * An empty prefix (`|`) stays undefined rather than defaulting to + * `local`: an unqualified row may be on any host, and callers use this to pick + * the host a destructive action runs against. + */ +export function getExecutionHostIdFromWorktreeHostIdentity( + identity: string +): ExecutionHostId | undefined { + const separatorIndex = identity.indexOf(HOST_SEPARATOR) + if (separatorIndex <= 0) { + return undefined + } + return parseExecutionHostId(identity.slice(0, separatorIndex))?.id +} + /** * The workspace id back out of an identity. * diff --git a/tests/e2e/worktree-delete-shortcut.spec.ts b/tests/e2e/worktree-delete-shortcut.spec.ts new file mode 100644 index 00000000000..29d4e1291c7 --- /dev/null +++ b/tests/e2e/worktree-delete-shortcut.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from './helpers/orca-app' +import { getAllWorktreeIds, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { pressShortcut } from './helpers/shortcuts' +import { worktreeRow } from './worktree-row-locators' + +async function createIsolatedWorktree( + page: Parameters[0] +): Promise { + const name = `e2e-delete-shortcut-${Date.now()}` + return page.evaluate(async (worktreeName) => { + const state = window.__store?.getState() + if (!state?.activeWorktreeId) { + throw new Error('No active worktree to derive repo from') + } + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((candidate) => candidate.id === state.activeWorktreeId) + if (!worktree) { + throw new Error('Active worktree was not found') + } + + const result = await state.createWorktree(worktree.repoId, worktreeName) + await state.fetchWorktrees(worktree.repoId) + return result.worktree.id + }, name) +} + +test.describe('Worktree Delete Shortcut', () => { + let createdWorktreeId: string | null = null + + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test.afterEach(async ({ orcaPage }) => { + if (!createdWorktreeId) { + return + } + const worktreeId = createdWorktreeId + createdWorktreeId = null + await orcaPage + .evaluate(async (id) => { + const state = window.__store?.getState() + await state?.removeWorktree( + { id, executionHostId: state.getKnownWorktreeById(id)?.hostId ?? null }, + true + ) + }, worktreeId) + .catch(() => undefined) + }) + + test('deletes the hovered worktree after Mod+Shift+Backspace confirmation', async ({ + orcaPage + }) => { + createdWorktreeId = await createIsolatedWorktree(orcaPage) + const worktreeId = createdWorktreeId + const row = worktreeRow(orcaPage, worktreeId) + await expect(row).toBeVisible() + + await row.hover() + await pressShortcut(orcaPage, 'Backspace', { shift: true }) + + const dialog = orcaPage.getByRole('dialog') + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'Delete Workspace', exact: true }).click() + + await expect + .poll(async () => getAllWorktreeIds(orcaPage), { + timeout: 15_000, + message: 'hovered worktree was not removed by the delete shortcut' + }) + .not.toContain(worktreeId) + createdWorktreeId = null + }) +}) From a70fe639939855713fdf52f25acfad34c4038868 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:46:30 -0700 Subject: [PATCH 07/12] fix(workspace-cleanup): read git for a pinned workspace before deleting it (#15370) `shouldReadWorkspaceCleanupGitEvidence` refused to read git whenever `blockers` included 'pinned' -- in a clause `forceGitCheck` could not override. So the confirm-time forced read, which exists precisely to decide whether a removal needs force, never ran for pinned rows. This is reachable today, not only after the planned verdict removal. 'pinned' is not a queue blocker (queue blockers are main-worktree, folder-repo, dismissed), so a pinned idle workspace is hand-selectable from the row checkbox right now. Its git evidence stays `clean: null, checkedAt: null`, and `shouldForceWorkspaceCleanupRemoval` returns true whenever git is unknown -- so it force-deletes with no evidence ever obtained. Moved 'pinned' into the cost-skip clause so `forceGitCheck` overrides it: broad scans still skip it, targeted preflights do not. main-worktree and folder-repo stay unconditional because both are refused before removal, so reading git for them is pure cost. Broad-scan performance is unchanged, and there is now a test pinning that: it passes before and after. The preflight test fails against the unfixed source. --- .../ipc/workspace-cleanup-broad-scan.test.ts | 38 +++++++++++++++++++ src/main/ipc/workspace-cleanup-candidate.ts | 15 +++++--- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts index ab65f6faa22..a5d19d26f7e 100644 --- a/src/main/ipc/workspace-cleanup-broad-scan.test.ts +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -132,6 +132,16 @@ function makeStore(repos: Repo[] = [REPO], allMeta: Record } as unknown as Store } +/** `repo-1::/repo-old` is in the shared fixture map, so pinning it needs a getWorktreeMeta override. */ +function makePinnedStore(): Store { + const pinned = makeWorktreeMeta({ isPinned: true, lastActivityAt: NOW - 40 * DAY_MS }) + return { + ...makeStore(), + getWorktreeMeta: (worktreeId: string) => + worktreeId === 'repo-1::/repo-old' ? pinned : META_BY_WORKTREE_ID[worktreeId] + } as Store +} + describe('workspace cleanup broad scan opt-in', () => { beforeEach(() => { vi.useFakeTimers() @@ -230,6 +240,34 @@ describe('workspace cleanup broad scan opt-in', () => { ) }) + it('skips git for a pinned workspace on a broad scan, which is the cost saving', async () => { + const store = makePinnedStore() + + await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + + expect(getStatusMock).not.toHaveBeenCalledWith( + expect.stringContaining('/repo-old'), + expect.anything() + ) + }) + + it('reads git for a pinned workspace on a targeted preflight, which decides force-removal', async () => { + // Why this matters: `pinned` is not a queue blocker, so a pinned idle workspace is + // hand-selectable today, and removal forces whenever git is unknown. Skipping the + // forced read meant it could be force-deleted with no evidence ever obtained. + const worktreeId = 'repo-1::/repo-old' + + const result = await scanWorkspaceCleanup(makePinnedStore(), { worktreeIds: [worktreeId] }) + + expect(getStatusMock).toHaveBeenCalledWith( + expect.stringContaining('/repo-old'), + expect.anything() + ) + const pinned = result.candidates.find((candidate) => candidate.worktreeId === worktreeId) + expect(pinned?.blockers).toContain('pinned') + expect(pinned?.git.checkedAt).not.toBeNull() + }) + it('keeps the legacy suggestion-only projection when the flag is absent', async () => { const result = await scanWorkspaceCleanup(makeStore()) diff --git a/src/main/ipc/workspace-cleanup-candidate.ts b/src/main/ipc/workspace-cleanup-candidate.ts index 58c56316acc..72f89161690 100644 --- a/src/main/ipc/workspace-cleanup-candidate.ts +++ b/src/main/ipc/workspace-cleanup-candidate.ts @@ -192,11 +192,16 @@ function shouldReadWorkspaceCleanupGitEvidence(args: { if ((skipGit && !forceGitCheck) || repoIsFolder || worktree.isMainWorktree) { return false } - if ( - blockers.includes('pinned') || - blockers.includes('main-worktree') || - blockers.includes('folder-repo') - ) { + // Why pinned sits with the cost skips and not the refusals: a pinned workspace is + // still queueable (`pinned` is not a queue blocker), so it can reach removal -- and + // removal forces whenever git is unknown. Skipping its git read on a broad scan is a + // fair saving; skipping it on the confirm-time forced read meant deleting with no + // evidence ever obtained. main-worktree and folder-repo stay unconditional: those are + // refused before removal, so reading git for them is pure cost. + if (blockers.includes('pinned') && !forceGitCheck) { + return false + } + if (blockers.includes('main-worktree') || blockers.includes('folder-repo')) { return false } From 8d08d0078deffb9b3c164013d2bc4a0a2a1ad6c1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:47:54 -0700 Subject: [PATCH 08/12] fix(codex): stop an unreadable legacy hooks.json from clearing managed trust (#16147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cleanupLegacySystemManagedHooks` reads `~/.codex/hooks.json` and, when it finds no hooks, removes Orca's managed trust entries from the system config.toml and deletes that home's grant-ledger record. Since the STA-4823 read classification landed, `readHooksJsonWithRaw` reports a genuine absence as `{ raw: null, config: {} }` and a failed read as `{ raw: null, config: null }`. Both still fell into the same branch, so a read that merely failed discarded hook approvals the user had already given — and the ledger record that would have let a later pass notice. Only the definitive-absence answer may reach the removal now. Found while reviewing #15417 and not covered by it: that PR fixed the classifier and this is a consumer of it that still collapsed the two answers. The sibling `assertHooksJsonGeneration` guard in codex-real-home-hook-install.ts has the same `existsSync ? read : null` shape and is deliberately NOT changed here. Measured, a permission denial leaves existsSync true and throws from the read, so that path already fails closed; a guard there could not be made to fail in a test and would be unprovable code. --- .../codex-hooks-read-denial-followup.test.ts | 151 ++++++++++++++++++ src/main/codex/hook-service.ts | 8 + 2 files changed, 159 insertions(+) create mode 100644 src/main/codex/codex-hooks-read-denial-followup.test.ts diff --git a/src/main/codex/codex-hooks-read-denial-followup.test.ts b/src/main/codex/codex-hooks-read-denial-followup.test.ts new file mode 100644 index 00000000000..1efb40f571d --- /dev/null +++ b/src/main/codex/codex-hooks-read-denial-followup.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import type * as NodeOs from 'node:os' +import type * as CodexManagedTrustReconciliation from './codex-managed-trust-reconciliation' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Follow-up to STA-4823: two more reads on the Codex hook paths acted on +// "absent" when the real answer was "could not read". Both were found while +// reviewing #15417 and are not covered by it. +// +// The denial shape here is the MEASURED one: chmod 000 / icacls deny leave +// existsSync true and stat succeeding, and fail only the content read +// (EACCES -13 on macOS, EPERM -4048 on Windows). No mode fakes existsSync, +// because no platform does. + +const denials = vi.hoisted(() => { + const state = { + paths: new Set(), + deny(path: string): void { + state.paths.add(path) + }, + reset(): void { + state.paths.clear() + }, + check(target: unknown, syscall: string): void { + if (typeof target !== 'string' || !state.paths.has(target)) { + return + } + const error: NodeJS.ErrnoException = new Error( + `EACCES: permission denied, ${syscall} '${target}'` + ) + error.code = 'EACCES' + error.errno = -13 + error.syscall = syscall + error.path = target + throw error + } + } + return state +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const guardRead = (fn: unknown, syscall: string): unknown => { + const original = fn as (...args: unknown[]) => unknown + const wrapped = (...args: unknown[]): unknown => { + denials.check(args[0], syscall) + return original(...args) + } + return Object.assign(wrapped, original) + } + const patched: Record = { + ...actual, + readFileSync: guardRead(actual.readFileSync, 'read'), + // Why: existsSync and stat stay HEALTHY. That is the whole point — a + // permission denial does not hide the file, it only refuses its contents. + openSync: Object.assign((...args: unknown[]): unknown => { + const flags = args[1] + if (flags === undefined || (typeof flags === 'string' && flags.startsWith('r'))) { + denials.check(args[0], 'open') + } + return (actual.openSync as (...a: unknown[]) => unknown)(...args) + }, actual.openSync) + } + return { ...patched, default: patched } +}) + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: homedirMock } +}) + +const realFs = await vi.importActual('node:fs') + +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + denials.reset() + fakeHomeDir = realFs.mkdtempSync(join(tmpdir(), 'orca-hooksread-home-')) + userDataDir = realFs.mkdtempSync(join(tmpdir(), 'orca-hooksread-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + realFs.mkdirSync(join(fakeHomeDir, '.codex'), { recursive: true }) +}) + +afterEach(() => { + denials.reset() + realFs.rmSync(fakeHomeDir, { recursive: true, force: true }) + realFs.rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('an unreadable legacy hooks.json must not clear managed trust or its ledger', () => { + it('returns before the removal branch instead of discarding approvals', async () => { + const legacyHooksPath = join(fakeHomeDir, '.codex', 'hooks.json') + realFs.writeFileSync(legacyHooksPath, JSON.stringify({ hooks: { Stop: [] } }), 'utf-8') + + const removeSpy = vi.fn() + vi.resetModules() + vi.doMock('./codex-managed-trust-reconciliation', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, removeCodexManagedHookTrustEntries: removeSpy } + }) + try { + // The removal only fires when a real-home grant is on record — without + // this the branch is inert and the test proves nothing either way. + const ledger = await import('./codex-trust-grant-ledger') + ledger.writeCodexTrustGrantLedgerHome( + join(fakeHomeDir, '.codex'), + { entries: { 'seed:1': { trustedHash: 'sha256:seed' } } } as never, + ledger.getCodexTrustGrantLedgerPath() + ) + + const hookService = await import('./hook-service') + denials.deny(legacyHooksPath) + + hookService._internals.cleanupLegacySystemManagedHooks() + + // `readHooksJsonWithRaw` catches the denial and reports {raw:null, + // config:null} — "could not read". Before the fix that fell into the same + // branch as "no hooks configured", which removes the managed trust + // entries AND their grant-ledger record. A read that failed is not + // permission to discard approvals the user already gave. + expect(removeSpy).not.toHaveBeenCalled() + } finally { + vi.doUnmock('./codex-managed-trust-reconciliation') + vi.resetModules() + } + }) +}) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index a0e30c5941c..270906012b5 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -601,6 +601,13 @@ function cleanupLegacySystemManagedHooks(): void { // Why: the pre-write guard below compares against these bytes; a separate // later read would let a concurrent save land between parse and snapshot. const { raw: previousRaw, config } = readHooksJsonWithRaw(legacyConfigPath) + // Why: `config === null` with no raw is the "could not read" answer, not the + // "no hooks here" one — the branch below removes managed trust entries AND + // their grant-ledger record, so acting on it would discard approvals over a + // read that merely failed. A genuine absence still returns `config: {}`. + if (config === null && previousRaw === null) { + return + } if (!config?.hooks || previousRaw === null) { if (hasRecordedRealHomeGrant) { removeSystemManagedHookTrustEntries(systemHomePath, legacyConfigPath) @@ -1637,6 +1644,7 @@ export class CodexHookService { export const codexHookService = new CodexHookService() export const _internals = { + cleanupLegacySystemManagedHooks, getManagedScript, installManagedHooksIntoWslRuntime, refreshWslRuntimeUserHooks, From 94f231737db13d68c2530137aadeeda87a9b31a9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:15:11 -0700 Subject: [PATCH 09/12] fix(agent-status): retire panes whose agent process is gone (STA-4612) (#15212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-status): retire panes whose agent process is gone (STA-4612) Agent status can hold `working` on a pane where no work is outstanding, and nothing closes the gap. A pane's Claude state is a join of a lead turn and three latches — the subagent roster, the background-task gate and the session-cron gate — and each is set by a hook and cleared only by another hook. Claude Code emits no terminating hook on `/exit`, `/clear`, Ctrl+C, crash, SIGKILL or terminal close, so every one of those latches is a claim with no owner and no expiry. The join is also materialised at ingest time and persisted, so a stale `working` survives restart and blocks hibernation, which requires `done`. Registering `SessionEnd` is not the fix: it covers roughly a third of exit paths (measured on 2.1.231/2.1.233; upstream anthropics/claude-code#17885 and #6428 are both closed as not planned). Nor is a TTL — `AGENT_STATUS_STALE_AFTER_MS` only decays the sidebar dot at read time while the stored row stays non-terminal. So the backstop is built from evidence Orca already owns. A session id that changes means the conversation was replaced. On the first hook of the new session — whatever that hook is — the previous session's own claims are void: its session crons and its one-shot subagents. Deliberately not voided: the background-task gate (a background shell is an OS process that survives `/clear`, and the previous inventory is positive evidence it was running), and `confirmedTeammate` rows (persistent in-process teammates a lead swap cannot end). The lead record is left to the incoming event's own fold. A certified process exit retires the pane. Orca already does this on every attributable PTY exit — `clearProviderPtyState` resolves the pane key and calls `clearPaneState` — but that resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored or reattached PTY may never rebuild. Those panes keep their row and latches for good. `onPtyExit` knows the keys teardown could not resolve, so it reconciles them from its own records. The certificate is `exitCode >= 0 || hostExitConfirmed || providerExitObserved`: a synthetic `-1` from a failed stop is not a death (the PTY can have survived it), while a real exit can also report `-1`, so neither the code nor the SSH surface predicate is sufficient alone. `providerExitObserved` is additive and separate from `hostExitConfirmed`, which also drives the liveness verdict and the SSH surface decision. A confirmed shell foreground is the `/exit` case: the agent died, the shell lived. That already dropped the row, but through `agentStatus:drop`, which by its own contract preserves a live pane's caches — so every latch survived and the next event resolved the pane back to `working`. It now routes through the reconciler instead, gated on a per-pane accepted-status generation rather than row identity: the confirming process read can take seconds, and `updatedAt` cannot order two writes inside one millisecond (the store deliberately admits equal timestamps). Cold start generalises the same way. The startup sweep required a restored subagent roster, so a stranded lead row, background-task gate or cron gate — the shapes with no child event left to reap them — were never candidates. Hibernation needs no change: with the above, those rows become genuinely `done` and the lockout resolves through the front door. A `restoredUnconfirmed` bypass in the planner would let it reclaim the heap of an agent that may be working. Not included: folding `background_tasks` from a child-attributed `SubagentStop`. Writing its test surfaced #11838's deliberate assertion that child inventories are not authoritative for lead-owned background work, and the listener says the same — "background_tasks is trusted only where unambiguous". An empty list on a `SubagentStop` does not prove the lead's shell ended, so the fold would have cleared a gate on evidence that establishes nothing. STA-4119's live-side question — whether a genuinely live background shell should hold the lead row after the lead turn ends — is untouched. This change extends gate-clearing to zero new triggers. * fix(agent-status): make the confirmed-shell reconcile survive its own drop The /exit leg never fired. `settleDeferredCommandFinishedStatusDrop` runs the paired drop before the reconcile, and `dropAgentStatus` cleared the per-pane accepted-status counter the reconcile's guard then read — so the guard compared a live anchor against a zeroed counter and skipped itself on every pane that had a status row, which is every pane worth reconciling. The existing test passed only because it used a pane with no row, where the drop early-returns and both sides read 0. Stop keying the guard on a counter a sibling teardown path can reset: the ordinal is now stamped on the row itself, derived from the row it replaces, so there is no side table to clear and a batched burst lands the same ordinals as the equivalent sequential writes. A removed row means "nothing reported", which is exactly what the paired drop leaves behind. Also: - Keep the `providerSessionOnly` resume identity that the paired dismissal mints when the shell outlived the agent; a certified PTY exit still takes it, since there is no pane left to resume into. - De-vacuum two guard tests. The confirmed-teammate pin never anchored a session owner, so the void it claimed to survive never ran; the unavailable-inspection pin asserted before the confirm ladder settled. Both now fail when their guard is removed. - Derive `hasLiveClaimsForPaneKey` from a predicate that lives beside `clearPaneCacheState`, so a new latch cannot be added to the teardown and silently missed by the claim check. - Drop the unreachable compact-`trigger` clauses; SessionStart is the whole guard. - Cover the connectionId arm of the exit certificate, where a provider-observed death and a preserved SSH surface are deliberately independent. * fix(agent-status): keep agent-status-types under its line cap main already sits exactly at the 300-line max-lines cap for this file, so the single `acceptedStatusSeq` field this branch adds pushed it to 301 once main's observation facet merged in. Declared the field as a mixin beside the observation facet instead. Both are per-write facets mixed into `AgentStatusEntry` rather than fields a reporter supplies, so they belong together — and the capped file loses a line rather than gaining one, since it already imports from that module. No lint suppression. * fix(agent-status): collapse the entry facets into one intersection The previous attempt still tripped max-lines: two mixins on one intersection wrap across two lines under oxfmt, so removing the field line bought nothing. Expose a single AgentStatusRowFacets that already includes the observation facet, so the entry intersects one short name on one line. The payload keeps intersecting the observation facet alone — it must not carry the renderer-local ordinal. Verified by formatting first and then linting, which is the order that catches this. * fix(agent-status): retire resume authority with dead panes --- .../ended-process-reconciliation.test.ts | 179 +++++++++++++++ .../restored-subagent-liveness-sweep.test.ts | 40 +++- src/main/agent-hooks/server.ts | 105 ++++++++- src/main/index.ts | 3 + src/main/ipc/agent-hooks.ts | 43 +--- src/main/ipc/agent-status-row-teardown-ipc.ts | 69 ++++++ .../pty-daemon-ssh-lease-lifecycle.test.ts | 8 +- .../pty-listener-teardown-and-orphans.test.ts | 1 + .../ipc/pty-runtime-kill-and-exit.test.ts | 8 +- src/main/ipc/pty.ts | 19 +- src/main/runtime/orca-runtime.ts | 60 +++-- ...y-exit-agent-status-reconciliation.test.ts | 91 ++++++++ src/preload/api/agent-status-api.ts | 3 + src/preload/index.ts | 3 + ...onnection-command-finished-cleanup.test.ts | 100 +++++++++ .../pty-connection-test-environment.ts | 3 +- .../pty-connection/pane-agent-identity.ts | 47 +++- src/renderer/src/store/slices/agent-status.ts | 9 + src/renderer/src/web/web-preload-api.ts | 1 + ...-hook-listener-session-replacement.test.ts | 209 ++++++++++++++++++ src/shared/agent-hook-listener.ts | 110 +++++++++ src/shared/agent-status-observation.ts | 11 + src/shared/agent-status-types.ts | 4 +- 23 files changed, 1043 insertions(+), 83 deletions(-) create mode 100644 src/main/agent-hooks/ended-process-reconciliation.test.ts create mode 100644 src/main/ipc/agent-status-row-teardown-ipc.ts create mode 100644 src/main/runtime/pty-exit-agent-status-reconciliation.test.ts create mode 100644 src/shared/agent-hook-listener-session-replacement.test.ts diff --git a/src/main/agent-hooks/ended-process-reconciliation.test.ts b/src/main/agent-hooks/ended-process-reconciliation.test.ts new file mode 100644 index 00000000000..0719b584d8a --- /dev/null +++ b/src/main/agent-hooks/ended-process-reconciliation.test.ts @@ -0,0 +1,179 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { AgentHookServer } from './server' + +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE = makePaneKey('tab-1', LEAF) + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-ended-process-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +async function startServer(): Promise { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: dir }) + return server +} + +function claudeRow(server: AgentHookServer, state: 'working' | 'waiting' | 'done'): void { + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload: { state, prompt: 'review the PR', agentType: 'claude' } + }) +} + +/** A row carrying a resumable provider session — the only shape `dropStatusEntry` retains. */ +function resumableClaudeRow(server: AgentHookServer): void { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + source: 'claude', + hookEventName: 'UserPromptSubmit', + launchToken: 'dead-launch-token', + providerSession: { key: 'session_id', id: 'resume-me' }, + payload: { state: 'working', prompt: 'review the PR', agentType: 'claude' } + }, + 'conn-a' + ) +} + +function paneState(server: AgentHookServer): string { + return server.getStatusSnapshotForPane(PANE)[0]?.state ?? 'missing' +} + +// The teardown that normally clears a dead pane depends on the spawn-time ptyPaneKey mapping, which +// a restored PTY may never rebuild — leaving the row and its latches with nothing left to retire +// them. See PLAN-STA-4612 §4.2. +describe('reconcileEndedProcessForPaneKeys', () => { + it('retires a working row whose process is certifiably dead', async () => { + const server = await startServer() + try { + claudeRow(server, 'working') + expect(paneState(server)).toBe('working') + + expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1) + + expect(paneState(server)).toBe('missing') + } finally { + server.stop() + } + }) + + it('retires a waiting row — a wait whose owner is dead is not a wait', async () => { + const server = await startServer() + try { + claudeRow(server, 'waiting') + + expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1) + + expect(paneState(server)).toBe('missing') + } finally { + server.stop() + } + }) + + it('clears Claude latches even when the stored row already reads done', async () => { + // An interrupted lead suppresses the gate while leaving the latch set, so a row can read `done` + // with a latch that would re-gate `working` on the pane's very next event. + const server = await startServer() + try { + claudeRow(server, 'done') + server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.add(PANE) + server._getStateForTests().claudeActiveSessionCronPaneKeys.add(PANE) + + expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1) + + expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(false) + expect(server._getStateForTests().claudeActiveSessionCronPaneKeys.has(PANE)).toBe(false) + } finally { + server.stop() + } + }) + + it('is a no-op for a pane with nothing to retire', async () => { + const server = await startServer() + try { + expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(0) + } finally { + server.stop() + } + }) + + it('keeps the resume identity a paired dismissal minted when the shell outlived the agent', async () => { + // The renderer's confirmed-shell route calls agentStatus:drop FIRST, which deliberately mints a + // providerSessionOnly remnant, then this. That remnant carries no state claim — it cannot gate a + // pane `working` — and the pane's PTY is still there to resume into, so retiring the pane's live + // claims must not take it. Without the option it goes with everything else. + const server = await startServer() + try { + resumableClaudeRow(server) + server.dropStatusEntry(PANE) + const afterDrop = server.getStatusSnapshotForPane(PANE)[0] + expect(afterDrop?.providerSessionOnly).toBe(true) + expect(afterDrop?.providerSession?.id).toBe('resume-me') + + expect( + server.reconcileEndedProcessForPaneKeys([PANE], { preserveResumeIdentity: true }) + ).toBe(1) + + const kept = server.getStatusSnapshotForPane(PANE)[0] + expect(kept?.providerSessionOnly).toBe(true) + expect(kept?.providerSession?.id).toBe('resume-me') + expect(kept?.launchToken).toBeUndefined() + // The live claims still went: a latch left behind would re-gate the pane on its next event. + expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(false) + + // The retained identity must not recreate dead launch authority after restart. + server.flushStatusPersistSync() + server.stop() + const restarted = await startServer() + expect(restarted.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined() + expect(restarted.getHydratedAuthorityCommitments()).toHaveLength(0) + restarted.stop() + } finally { + server.stop() + } + }) + + it('takes the resume identity too on a certified PTY exit, where no pane is left to resume into', async () => { + const server = await startServer() + try { + resumableClaudeRow(server) + server.dropStatusEntry(PANE) + + expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1) + + expect(paneState(server)).toBe('missing') + } finally { + server.stop() + } + }) + + it('drops the session owner so a rebound pane is not read as a session replacement', async () => { + const server = await startServer() + try { + claudeRow(server, 'working') + server._getStateForTests().claudeSessionOwnerByPaneKey.set(PANE, 'session-a') + + server.reconcileEndedProcessForPaneKeys([PANE]) + + expect(server._getStateForTests().claudeSessionOwnerByPaneKey.has(PANE)).toBe(false) + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts index 9811773eb07..b3257e6972c 100644 --- a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts +++ b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts @@ -211,7 +211,10 @@ describe('restored subagent liveness sweep', () => { } }) - it('does not probe a hydrated Claude pane without restored rows', async () => { + it('reaps a hydrated non-terminal row with no roster at all (STA-4612)', async () => { + // A restored roster is only one shape of stranded claim. A lead row left `working` with no + // children has nothing left to retire it — no child event, no inventory, no hook — so it holds + // the pane working for good and locks its agent out of hibernation. const server = await restartWithInFlightSubagent({ subagents: [] }) const probeLiveLocalPty = vi.fn(() => false) try { @@ -220,9 +223,42 @@ describe('restored subagent liveness sweep', () => { probeLiveLocalPty, persistedPtyIdByPaneKey: { [PANE]: PTY } }) + ).toBe(1) + + expect(probeLiveLocalPty).toHaveBeenCalled() + expect(paneStatus(server).state).toBe('missing') + } finally { + server.stop() + } + }) + + it('keeps a hydrated non-terminal row with no roster when its PTY is still live', async () => { + const server = await restartWithInFlightSubagent({ subagents: [] }) + try { + expect( + await sweepWith(server, { + probeLiveLocalPty: () => true, + persistedPtyIdByPaneKey: { [PANE]: PTY } + }) ).toBe(0) - expect(probeLiveLocalPty).not.toHaveBeenCalled() + expect(paneStatus(server).state).toBe('working') + } finally { + server.stop() + } + }) + + it('keeps a hydrated non-terminal row with no roster when the probe cannot prove either state', async () => { + const server = await restartWithInFlightSubagent({ subagents: [] }) + try { + expect( + await sweepWith(server, { + probeLiveLocalPty: () => null, + persistedPtyIdByPaneKey: { [PANE]: PTY } + }) + ).toBe(0) + + expect(paneStatus(server).state).toBe('working') } finally { server.stop() } diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 65b7b1479fd..9f39460e719 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -12,6 +12,7 @@ import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' import { clearAllListenerCaches, clearPaneCacheState, + paneHasStateClaims, clearClaudeAnsweredQuestionWait, createHookListenerState, getEndpointFileName, @@ -2652,28 +2653,87 @@ export class AgentHookServer { this.notifyStatusChangeListeners() } + /** The resume-identity remnant of a dropped row: a `providerSessionOnly` entry carries no state + * claim — it cannot gate a pane `working` — so it survives teardowns that end the pane's live + * claims. Returns null when the row has no resumable session to keep. */ + private toRetainedProviderSessionRow( + entry: EnrichedAgentHookEventPayload | null | undefined + ): EnrichedAgentHookEventPayload | null { + if ( + !entry?.providerSession || + !entry.payload.agentType || + entry.payload.agentType === 'unknown' + ) { + return null + } + const { launchToken: _launchToken, ...resumeIdentity } = entry + return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true } + } + /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */ dropStatusEntry(paneKey: string): void { const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) if (!deleted) { return } - if ( - deleted.providerSession && - deleted.payload.agentType && - deleted.payload.agentType !== 'unknown' - ) { - const retained: EnrichedAgentHookEventPayload = { - ...deleted, - providerSessionOnly: true, - retainedForLiveness: true - } + const retained = this.toRetainedProviderSessionRow(deleted) + if (retained) { this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) } this.scheduleStatusPersist() this.notifyStatusChangeListeners() } + /** Retire panes whose owning process is certifiably dead. + * + * The ordinary teardown already does this: every attributable PTY exit reaches + * `clearProviderPtyState`, which resolves the pane key and calls `clearPaneState`. But that + * resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored/reattached PTY may + * never rebuild — so those panes keep a `working` row and its latches for good, with no hook left + * to retire them. This is the same operation reached from the runtime's own pane-key knowledge, + * so a dead pane is cleaned up identically however its keys were resolved. */ + reconcileEndedProcessForPaneKeys( + paneKeys: Iterable, + options?: { + /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still + * be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop` + * minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to + * resume into, and dropping it matches what `clearProviderPtyState` already does. */ + preserveResumeIdentity?: boolean + } + ): number { + let cleared = 0 + for (const paneKey of paneKeys) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) { + continue + } + const retained = options?.preserveResumeIdentity + ? this.toRetainedProviderSessionRow( + this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + ) + : null + this.clearPaneState(resolvedPaneKey) + if (retained) { + this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + cleared += 1 + } + return cleared + } + + /** Anything a dead pane could still be asserting: a row, or a latch that would re-gate one through + * `resolveClaudePaneState` on the pane's next event even after the row reads `done`. The list + * itself lives beside `clearPaneCacheState`, so adding a latch cannot leave this behind in a + * different file. */ + private hasLiveClaimsForPaneKey(paneKey: string): boolean { + return paneHasStateClaims(this.state, paneKey) + } + /** Clear statuses proven to belong to one lost SSH transport. */ clearStatusEntriesForConnection(connectionId: string): void { const normalizedConnectionId = connectionId.trim() @@ -2704,6 +2764,7 @@ export class AgentHookServer { this.state.claudeLeadStateByPaneKey.delete(paneKey) this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) + this.state.claudeSessionOwnerByPaneKey.delete(paneKey) } } } @@ -2894,9 +2955,15 @@ export class AgentHookServer { enriched.payload.agentType === 'claude' && enriched.connectionId === null && isLocalExecutionHost(enriched.worktreeId) && - claudeRosterHasRestoredSnapshotSubagent( + // Why: a restored roster is only one shape of stranded claim. A lead row left non-terminal, + // or a background-task/cron latch nothing will refresh, strands the pane just as + // permanently — and unlike the roster case there is no child event left to reap it. + (claudeRosterHasRestoredSnapshotSubagent( this.state.claudeSubagentRosterByPaneKey.get(paneKey) - ) && + ) || + enriched.payload.state !== 'done' || + this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + this.state.claudeActiveSessionCronPaneKeys.has(paneKey)) && !this.runtimeObservedStatusPaneKeys.has(paneKey) ) { candidates.push({ paneKey, entry: enriched }) @@ -2924,6 +2991,20 @@ export class AgentHookServer { continue } if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) { + // Why: the roster reap only speaks for restored child rows. A pane whose PTY is provably + // gone and whose claim is a lead row or a latch has nothing for it to reap, so retire the + // pane the same way an observed exit would — otherwise the widened candidate set is inert. + // + // Why delete rather than downgrade to `done` like the reap branch below: that branch has a + // real turn to describe — a parent whose children it just reaped — while these panes' only + // claim IS the stale non-terminal row. Rewriting a `waiting`/`blocked` row to `done` would + // invent a completion that never happened, and leaving it non-terminal keeps the bug. This + // sweep stands in for the exit Orca never observed, so it does what that exit does: + // `clearProviderPtyState` -> `clearPaneState`. + if (this.hasLiveClaimsForPaneKey(paneKey)) { + this.clearPaneState(paneKey) + changedPanes += 1 + } continue } changedPanes += 1 diff --git a/src/main/index.ts b/src/main/index.ts index ae8fed9981d..da9c334c7e1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2719,6 +2719,9 @@ void app.whenReady().then(async () => { agentHookServer.attestCompatibilityAuthority(candidate), retireAgentHookCompatibilityAuthority: (paneKey) => agentHookServer.retirePaneAuthority(paneKey), + reconcileAgentStatusForEndedProcess: (paneKeys) => { + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys) + }, canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. getAdditionalAiVaultCodexHomePaths: () => diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 5a8e3f69ae4..5615fa38208 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -6,13 +6,9 @@ import type { } from '../../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent' -import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' +import { agentHookServer } from '../agent-hooks/server' import { ampHookService } from '../amp/hook-service' -import { - clearMigrationUnsupportedPtysByTabPrefix, - clearMigrationUnsupportedPtysForPaneKey, - getMigrationUnsupportedPtySnapshot -} from '../agent-hooks/migration-unsupported-pty-state' +import { getMigrationUnsupportedPtySnapshot } from '../agent-hooks/migration-unsupported-pty-state' import { claudeHookService } from '../claude/hook-service' import { codexHookService } from '../codex/hook-service' import { geminiHookService } from '../gemini/hook-service' @@ -27,10 +23,10 @@ import { devinHookService } from '../devin/hook-service' import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' import { registerAgentPaneAuthorityIpcHandlers } from './agent-pane-authority-ipc' +import { registerAgentStatusRowTeardownIpcHandlers } from './agent-status-row-teardown-ipc' import { createAgentPaneAuthorityOwnership } from './agent-pane-authority-ownership' import { enrichAgentStatusIpcPayload, - isValidAgentStatusDropTabId, type AgentStatusRuntimeEnrichment } from './agent-status-ipc-boundary' @@ -70,38 +66,7 @@ export function registerAgentHookHandlers( ipcMain.removeHandler('agentStatus:inferInterrupt') ipcMain.removeHandler('agentStatus:inferQuestionAnswered') ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') - // Why: agentStatus:drop is sent fire-and-forget from the renderer via - // ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't - // round-trip a response. Removing first keeps re-registration safe even - // though the module-level registered guard already prevents re-entry today. - ipcMain.removeAllListeners('agentStatus:drop') - ipcMain.removeAllListeners('agentStatus:dropByTabPrefix') - ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => { - if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { - return - } - try { - // Why: dropStatusEntry (not clearPaneState) is correct here — the user is - // dismissing a status row, not tearing down a PTY. clearPaneState would also - // wipe the per-pane prompt/tool caches, which the next hook event for that - // (still-alive) pane needs to render a coherent row. - agentHookServer.dropStatusEntry(paneKey) - clearMigrationUnsupportedPtysForPaneKey(paneKey) - } catch (err) { - console.warn('[agent-hooks] dropStatusEntry failed:', err) - } - }) - ipcMain.on('agentStatus:dropByTabPrefix', (_event, tabId: unknown) => { - if (!isValidAgentStatusDropTabId(tabId)) { - return - } - try { - agentHookServer.dropStatusEntriesByTabPrefix(tabId) - clearMigrationUnsupportedPtysByTabPrefix(tabId) - } catch (err) { - console.warn('[agent-hooks] dropStatusEntriesByTabPrefix failed:', err) - } - }) + registerAgentStatusRowTeardownIpcHandlers() registerAgentPaneAuthorityIpcHandlers({ ownsPty: createAgentPaneAuthorityOwnership({ getPtyIdForPaneKey: dependencies.getPtyIdForPaneKey, diff --git a/src/main/ipc/agent-status-row-teardown-ipc.ts b/src/main/ipc/agent-status-row-teardown-ipc.ts new file mode 100644 index 00000000000..e33e1c93789 --- /dev/null +++ b/src/main/ipc/agent-status-row-teardown-ipc.ts @@ -0,0 +1,69 @@ +import { ipcMain } from 'electron' +import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' +import { + clearMigrationUnsupportedPtysByTabPrefix, + clearMigrationUnsupportedPtysForPaneKey +} from '../agent-hooks/migration-unsupported-pty-state' +import { isValidAgentStatusDropTabId } from './agent-status-ipc-boundary' + +/** + * The three renderer-initiated ways a status row goes away. All fire-and-forget + * (`ipcRenderer.send` → `ipcMain.on`), so none round-trips a response; removing the + * listeners first keeps re-registration safe. + * + * They are NOT interchangeable. A dismissal keeps the pane's per-pane caches because its agent may + * still be alive; a confirmed process exit must take them too, or a surviving Claude latch resolves + * the pane's next event straight back to `working`. + */ +export function registerAgentStatusRowTeardownIpcHandlers(): void { + ipcMain.removeAllListeners('agentStatus:drop') + ipcMain.removeAllListeners('agentStatus:reconcileEndedProcess') + ipcMain.removeAllListeners('agentStatus:dropByTabPrefix') + + ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => { + if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { + return + } + try { + // Why: dropStatusEntry (not clearPaneState) is correct here — the user is + // dismissing a status row, not tearing down a PTY. clearPaneState would also + // wipe the per-pane prompt/tool caches, which the next hook event for that + // (still-alive) pane needs to render a coherent row. + agentHookServer.dropStatusEntry(paneKey) + clearMigrationUnsupportedPtysForPaneKey(paneKey) + } catch (err) { + console.warn('[agent-hooks] dropStatusEntry failed:', err) + } + }) + + ipcMain.on('agentStatus:reconcileEndedProcess', (_event, paneKey: unknown) => { + if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { + return + } + try { + // Why: a process-table-confirmed agent exit is exactly the case the dismissal above excludes + // — the pane's agent is NOT still alive — so its latches must go with the row (STA-4612). + agentHookServer.reconcileEndedProcessForPaneKeys([paneKey], { + // Why: this route only fires on a confirmed shell foreground, so the PTY outlived the + // agent. The row's resume identity is still usable in that very pane — only its live + // claims are dead. + preserveResumeIdentity: true + }) + clearMigrationUnsupportedPtysForPaneKey(paneKey) + } catch (err) { + console.warn('[agent-hooks] reconcileEndedProcessForPaneKeys failed:', err) + } + }) + + ipcMain.on('agentStatus:dropByTabPrefix', (_event, tabId: unknown) => { + if (!isValidAgentStatusDropTabId(tabId)) { + return + } + try { + agentHookServer.dropStatusEntriesByTabPrefix(tabId) + clearMigrationUnsupportedPtysByTabPrefix(tabId) + } catch (err) { + console.warn('[agent-hooks] dropStatusEntriesByTabPrefix failed:', err) + } + }) +} diff --git a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts index ae4de312c55..c99c7f7b93d 100644 --- a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts +++ b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts @@ -289,7 +289,9 @@ describe('registerPtyHandlers', () => { await Promise.resolve() expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) - expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, { + providerExitObserved: true + }) expect( mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]]) @@ -342,7 +344,9 @@ describe('registerPtyHandlers', () => { await expect(stopPromise).resolves.toBe(true) expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) - expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, { + providerExitObserved: true + }) expect( mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]]) diff --git a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts index 46b15d86bcb..ae749f8dbe0 100644 --- a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts +++ b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts @@ -440,6 +440,7 @@ describe('registerPtyHandlers', () => { expect(killSpy).toHaveBeenCalled() expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1, spawnResult.incarnationId, { + providerExitObserved: true, cause: { kind: 'unknown', reason: 'stop_unverified' } }) const listed = await getLocalPtyProvider().listProcesses() diff --git a/src/main/ipc/pty-runtime-kill-and-exit.test.ts b/src/main/ipc/pty-runtime-kill-and-exit.test.ts index 478212b6c15..2200b2b6043 100644 --- a/src/main/ipc/pty-runtime-kill-and-exit.test.ts +++ b/src/main/ipc/pty-runtime-kill-and-exit.test.ts @@ -287,7 +287,9 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:kill')!(null, { id: 'local-pty' }) expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) - expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, { + providerExitObserved: true + }) expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual( [['pty:exit', { id: 'local-pty', code: 0 }]] ) @@ -529,7 +531,9 @@ describe('registerPtyHandlers', () => { seq: 13, rawLength: 'daemon output'.length }) - expect(runtime.onPtyExit).toHaveBeenCalledWith(result.id, 0, undefined, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith(result.id, 0, undefined, { + providerExitObserved: true + }) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { id: result.id, code: 0 diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index cac4538be37..3adcdb56376 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -2575,7 +2575,12 @@ export function registerPtyHandlers( clearProviderPtyState(id) ptyOwnership.delete(id) markClaudePtyExited(id) - runtime?.onPtyExit(id, code, incarnationId, cause ? { cause } : undefined) + // Why: the provider's physical exit callback is a death certificate even when node-pty + // reports the code as -1; only the synthesized failed/unverified stop calls omit this. + runtime?.onPtyExit(id, code, incarnationId, { + providerExitObserved: true, + ...(cause ? { cause } : {}) + }) }, onData: (id, data, timestamp, sequenceChars, transformed) => runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed) @@ -4116,12 +4121,12 @@ export function registerPtyHandlers( clearProviderPtyState(payload.id) ptyOwnership.delete(payload.id) markClaudePtyExited(payload.id) - runtime?.onPtyExit( - payload.id, - payload.code, - payload.incarnationId, - payload.cause ? { cause: payload.cause } : undefined - ) + // Why: same physical-exit certificate as the LocalPtyProvider branch above — this is the + // daemon/WSL callback, and on macOS it is the branch that actually runs. + runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId, { + providerExitObserved: true, + ...(payload.cause ? { cause: payload.cause } : {}) + }) } // Why not the whole payload: the exit cause is a main-process fact for the // runtime's records; the renderer's pty:exit contract stays as it was. diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ab470d78533..60a20f7929f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3632,6 +3632,9 @@ export class OrcaRuntimeService { }) => AgentHookAuthorityAttestation | null) | null private readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null + private readonly reconcileAgentStatusForEndedProcessFn: + | ((paneKeys: Iterable) => void) + | null private readonly canRecoverPersistentLocalPtysFn: () => boolean private readonly buildAgentHookPtyEnv: (() => Record) | null private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus @@ -3713,6 +3716,7 @@ export class OrcaRuntimeService { terminalProvenance: 'current_runtime' | 'restored' }) => AgentHookAuthorityAttestation | null retireAgentHookCompatibilityAuthority?: (paneKey: string) => void + reconcileAgentStatusForEndedProcess?: (paneKeys: Iterable) => void canRecoverPersistentLocalPtys?: () => boolean // Why: codex-home paths for the Agent Session History scan must be sourced // here, not via the window-only registerCoreHandlers path — that path never @@ -3755,6 +3759,7 @@ export class OrcaRuntimeService { deps?.attestAgentHookCompatibilityAuthority ?? null this.retireAgentHookCompatibilityAuthorityFn = deps?.retireAgentHookCompatibilityAuthority ?? null + this.reconcileAgentStatusForEndedProcessFn = deps?.reconcileAgentStatusForEndedProcess ?? null this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) // Why: configure the shared AiVault scan cache from a serve-mode-reachable // seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions @@ -13962,6 +13967,27 @@ export class OrcaRuntimeService { } } + /** Every pane key this PTY could be addressed by. Independent of launch authority: an ordinary + * restored PTY has neither a launch token nor a receipt, and those are exactly the panes whose + * spawn-time `ptyPaneKey` mapping teardown could not resolve. */ + private collectPaneKeysForPty(ptyId: string): Set { + const paneKeys = new Set() + const pty = this.ptysById.get(ptyId) + if (pty?.paneKey && parsePaneKey(pty.paneKey)) { + paneKeys.add(pty.paneKey) + } + const receipt = this.restoredOrchestrationAuthorityByPtyId.get(ptyId) + if (receipt?.paneKey && parsePaneKey(receipt.paneKey)) { + paneKeys.add(receipt.paneKey) + } + for (const leaf of this.getLeavesForPty(ptyId)) { + if (isValidTerminalTabId(leaf.tabId) && isTerminalLeafId(leaf.leafId)) { + paneKeys.add(makePaneKey(leaf.tabId, leaf.leafId)) + } + } + return paneKeys + } + private retirePtyAgentLaunchAuthority(ptyId: string): void { const pty = this.ptysById.get(ptyId) if (!pty) { @@ -13971,21 +13997,10 @@ export class OrcaRuntimeService { if (!pty.launchToken && !receipt) { return } + const paneKeys = this.collectPaneKeysForPty(ptyId) this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) pty.launchToken = null pty.launchIncarnationId = null - const paneKeys = new Set() - if (pty.paneKey && parsePaneKey(pty.paneKey)) { - paneKeys.add(pty.paneKey) - } - if (receipt?.paneKey && parsePaneKey(receipt.paneKey)) { - paneKeys.add(receipt.paneKey) - } - for (const leaf of this.getLeavesForPty(ptyId)) { - if (isValidTerminalTabId(leaf.tabId) && isTerminalLeafId(leaf.leafId)) { - paneKeys.add(makePaneKey(leaf.tabId, leaf.leafId)) - } - } for (const paneKey of paneKeys) { this.retireAgentHookCompatibilityAuthorityFn?.(paneKey) } @@ -15201,7 +15216,14 @@ export class OrcaRuntimeService { ptyId: string, exitCode: number, exitIncarnationId?: PtyIncarnationId, - options?: { hostExitConfirmed?: boolean; cause?: TerminalExitCause } + options?: { + hostExitConfirmed?: boolean + cause?: TerminalExitCause + /** The provider's own physical-exit callback fired. Separate from `hostExitConfirmed`, which + * also drives the SSH surface decision and the liveness verdict: node-pty reports real exits + * as -1, so the numeric code alone cannot tell a dead process from a failed stop. */ + providerExitObserved?: boolean + } ): void { const pty = this.ptysById.get(ptyId) if (exitIncarnationId && pty?.incarnationId && exitIncarnationId !== pty.incarnationId) { @@ -15227,6 +15249,9 @@ export class OrcaRuntimeService { pty?.connectionId != null && exitCode < 0 && options?.hostExitConfirmed !== true + // Why: collect before retirePtyAgentLaunchAuthority, which deletes the restored-authority + // receipt a receipt-only pane's key comes from. + const exitPaneKeys = this.collectPaneKeysForPty(ptyId) if (preservesAbnormalSshSurface) { if (this.getPtyLivenessVerdict(ptyId)?.status !== 'unverifiable') { this.markPtyLivenessUnverifiable(ptyId, SSH_EXIT_UNCONFIRMED_REASON) @@ -15235,6 +15260,15 @@ export class OrcaRuntimeService { } else { this.retirePtyAgentLaunchAuthority(ptyId) } + // Why: a synthetic -1 from a failed or unverified stop is not a death certificate (the PTY can + // have survived it), while a real exit can also report -1 — so neither the numeric code nor the + // SSH surface predicate is sufficient on its own. Decided separately from + // preservesAbnormalSshSurface, which a host-confirmed negative SSH exit would otherwise skip. + const processDeathCertified = + exitCode >= 0 || options?.hostExitConfirmed === true || options?.providerExitObserved === true + if (processDeathCertified && exitPaneKeys.size > 0) { + this.reconcileAgentStatusForEndedProcessFn?.(exitPaneKeys) + } const incarnationId = exitIncarnationId ?? pty?.incarnationId ?? diff --git a/src/main/runtime/pty-exit-agent-status-reconciliation.test.ts b/src/main/runtime/pty-exit-agent-status-reconciliation.test.ts new file mode 100644 index 00000000000..f516e4f5b6d --- /dev/null +++ b/src/main/runtime/pty-exit-agent-status-reconciliation.test.ts @@ -0,0 +1,91 @@ +/** + * A PTY that exits without a resolvable spawn-time pane key never reaches + * `agentHookServer.clearPaneState`, so its row and every Claude latch outlive the process with + * nothing left to retire them (STA-4612). `onPtyExit` knows the pane keys that teardown could not + * resolve, so it reconciles them — but only on a real death certificate. + */ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { makePaneKey } from '../../shared/stable-pane-id' + +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE = makePaneKey('tab-1', LEAF) +const PTY = 'wt-1__pty-1' + +type RuntimeInternals = { + ptysById: Map +} + +function runtimeWithBoundPane( + reconcile: (paneKeys: Iterable) => void, + options: { connectionId?: string } = {} +): OrcaRuntimeService { + const runtime = new OrcaRuntimeService(null, undefined, { + reconcileAgentStatusForEndedProcess: reconcile + }) + const ptys = (runtime as unknown as RuntimeInternals).ptysById + ptys.set(PTY, { + ptyId: PTY, + paneKey: PANE, + worktreeId: 'wt-1', + tabId: 'tab-1', + ...(options.connectionId ? { connectionId: options.connectionId } : {}) + } as never) + return runtime +} + +function reconciledPaneKeys(calls: Iterable[]): string[] { + return calls.flatMap((keys) => [...keys]) +} + +describe('onPtyExit agent-status reconciliation', () => { + it('reconciles on a normal zero exit code', () => { + const reconcile = vi.fn() + runtimeWithBoundPane(reconcile).onPtyExit(PTY, 0) + + expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE) + }) + + it('reconciles a physical -1 exit reported by the provider callback', () => { + // node-pty forwards real exits as -1 (local-pty-provider-shutdown.test.ts pins this), so the + // numeric code alone cannot separate a dead process from a failed stop. + const reconcile = vi.fn() + runtimeWithBoundPane(reconcile).onPtyExit(PTY, -1, undefined, { providerExitObserved: true }) + + expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE) + }) + + it('reconciles a host-confirmed negative exit', () => { + const reconcile = vi.fn() + runtimeWithBoundPane(reconcile).onPtyExit(PTY, -1, undefined, { hostExitConfirmed: true }) + + expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE) + }) + + it('reconciles a provider-observed -1 on a pane bound to a transport, where the SSH surface is preserved', () => { + // A WSL pane carries a `wsl:*` connectionId, so a negative exit keeps the abnormal-SSH surface + // (liveness unverifiable, launch authority not retired) — but the daemon provider's own exit + // callback still witnessed the process die. The two decisions are deliberately independent; + // without this the connectionId arm of the certificate is never exercised. + const reconcile = vi.fn() + runtimeWithBoundPane(reconcile, { connectionId: 'wsl:Ubuntu' }).onPtyExit(PTY, -1, undefined, { + providerExitObserved: true + }) + + expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE) + }) + + it('does NOT reconcile a transport-bound synthetic -1, whose remote PTY is designed to survive it', () => { + const reconcile = vi.fn() + runtimeWithBoundPane(reconcile, { connectionId: 'ssh-conn-1' }).onPtyExit(PTY, -1) + + expect(reconcile).not.toHaveBeenCalled() + }) + + it('does NOT reconcile a synthetic -1 from a failed stop, whose PTY may have survived', () => { + const reconcile = vi.fn() + runtimeWithBoundPane(reconcile).onPtyExit(PTY, -1) + + expect(reconcile).not.toHaveBeenCalled() + }) +}) diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 900b3ea4490..fd6da88e351 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -30,6 +30,9 @@ export type AgentStatusApi = { getMigrationUnsupportedSnapshot: () => Promise /** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */ drop: (paneKey: string) => void + /** Retire a pane whose agent process is proven gone — clears the row AND the per-pane caches a + * dismissal deliberately keeps. Not `drop`: that one is a user dismissal of a live pane's row. */ + reconcileEndedProcess: (paneKey: string) => void /** Drop every cached hook status under one terminal tab prefix. Fire-and-forget. */ dropByTabPrefix: (tabId: string) => void /** Permanently retire one pane's hook authority while siblings stay live. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index b8a00c6d647..4c30124a6da 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5032,6 +5032,9 @@ const api = { drop: (paneKey: string): void => { ipcRenderer.send('agentStatus:drop', paneKey) }, + reconcileEndedProcess: (paneKey: string): void => { + ipcRenderer.send('agentStatus:reconcileEndedProcess', paneKey) + }, /** Drop all cached hook statuses under one terminal tab prefix; fired on explicit tab close even without a local row. */ dropByTabPrefix: (tabId: string): void => { ipcRenderer.send('agentStatus:dropByTabPrefix', tabId) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts index dc3a6c4e2d5..5a6de84e03c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts @@ -328,6 +328,106 @@ describe('connectPanePty', () => { shellForeground: true }) expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + // Why: `agentStatus:drop` deliberately preserves a live pane's per-pane caches, so a + // process-confirmed agent exit must go through the reconcile route instead — otherwise a + // surviving Claude latch resolves the pane's next event straight back to 'working' (STA-4612). + expect(window.api.agentStatus.reconcileEndedProcess).toHaveBeenCalledWith(paneKey) + }) + + it('reconciles a pane that HAS a status row, whose own drop removes it first', async () => { + // Why this shape: the sibling test above covers a pane with no row, where the deferred drop + // early-returns and never touches the pane's accepted-status bookkeeping. That is not the + // production case. A pane worth reconciling always HAS a row — and settling runs the drop + // BEFORE the reconcile, so the reconcile must survive its own drop having already fired. + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + // Both imported after vi.resetModules() so they share pty-connection's module registry; a + // static top-level import here is a DIFFERENT instance and the assertion goes vacuous. + const { createTestStore } = await import('@/store/slices/store-test-helpers') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') + vi.mocked(window.api.agentStatus.reconcileEndedProcess).mockClear() + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-claude-row-confirmed-shell' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + + // A real accepted status write, through the real slice, so the row carries whatever + // bookkeeping the store stamps on an accepted write. `working` is the shape STA-4612 is about: + // a latch is holding the pane open and the agent is about to exit out from under it. + const realStore = createTestStore() + realStore + .getState() + .setAgentStatus(paneKey, { state: 'working', prompt: 'ship it', agentType: 'claude' }) + const row = realStore.getState().agentStatusByPaneKey[paneKey] + expect(row).toBeDefined() + mockStoreState.agentStatusByPaneKey[paneKey] = row + // The real dismissal removes the row and runs the slice's own teardown; mirror both, or the + // ordering this test exists to pin is never exercised. + mockStoreState.dropAgentStatus.mockImplementation((key: string) => { + delete mockStoreState.agentStatusByPaneKey[key] + realStore.getState().dropAgentStatus(key) + }) + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350) + + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + expect(window.api.agentStatus.reconcileEndedProcess).toHaveBeenCalledWith(paneKey) + }) + + it('does NOT reconcile when the process check could not confirm a shell', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) + vi.mocked(window.api.agentStatus.reconcileEndedProcess).mockClear() + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-unconfirmed-shell' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + // Run the whole confirm ladder. At 350ms the unavailable branch has not settled yet, so + // asserting there proves nothing — it would pass just as happily if this path DID tear the + // pane down a moment later. + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + // The unavailable branch really ran: it publishes an unproven foreground before settling. + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: false + }) + expect(window.api.agentStatus.reconcileEndedProcess).not.toHaveBeenCalled() }) it('disarms stale TUI modes in the emulator after a confirmed return to shell', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts index 6e9f24f3efe..eb3a40b6803 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts @@ -72,7 +72,8 @@ export function installTerminalTestGlobals(): void { restoreTerminalFit: vi.fn().mockResolvedValue({ restored: true }) }, agentStatus: { - inferInterrupt: vi.fn().mockResolvedValue(false) + inferInterrupt: vi.fn().mockResolvedValue(false), + reconcileEndedProcess: vi.fn() } }, dispatchEvent: vi.fn(), diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index 9f1b2b7ebe7..da59cd06c79 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -88,12 +88,45 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { } } session.deferredCommandFinishedStatusDrop = null + session.deferredConfirmedShellReconcile = null + /** The pane's foreground was proven to be a shell, so its agent exited. Unlike a user dismissal, + * this must also retire the main-side per-pane caches — a surviving Claude latch resolves the + * next event straight back to `working`. + * + * Ordered by the row's `acceptedStatusSeq`, not by row identity or `updatedAt`: the confirming + * process read can take seconds, an unrelated field moving in that window is not evidence the + * agent is alive, and a genuinely newer row can share the anchor's millisecond. Reading the token + * off the row is what makes the ordering safe — the paired drop runs FIRST and removes the row, + * and a removed row means nothing reported, not "the anchor is gone". + * + * Residual: a row deleted by some OTHER path mid-window and then rebuilt restarts its count, so a + * pane armed at 1 that reports exactly once more compares equal. It needs a foreign drop inside + * the confirm window; the relaunch case that would otherwise hit it is already discarded by + * onCommandStarted, and the cost is retiring a pane the process table just proved is a shell. */ + const reconcileEndedProcessIfPaneQuiet = (armedAcceptedStatusSeq: number | undefined): void => { + const current = useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + if (current && current.acceptedStatusSeq !== armedAcceptedStatusSeq) { + return + } + // Why: main-side only. The renderer row and launch config are already owned by the deferred + // drop above; what that path cannot reach is the hook server's per-pane Claude latches, which + // `agentStatus:drop` deliberately preserves for a still-live pane. Main echoes its own clear + // back through the pane-status-cleared channel, so both sides stay consistent. + window.api?.agentStatus?.reconcileEndedProcess?.(session.cacheKey) + } session.visibleForegroundSamplePending = false session.visibleForegroundSampleSettled = false - session.settleDeferredCommandFinishedStatusDrop = (): void => { + session.settleDeferredCommandFinishedStatusDrop = ( + options: { confirmedShell?: boolean } = {} + ): void => { const dropStatus = session.deferredCommandFinishedStatusDrop + const reconcile = session.deferredConfirmedShellReconcile session.deferredCommandFinishedStatusDrop = null + session.deferredConfirmedShellReconcile = null dropStatus?.() + if (options.confirmedShell) { + reconcile?.() + } } session.isForegroundTrackingAllowed = (id: string): boolean => { if (isRemoteRuntimePtyId(id) || parseAppSshPtyId(id) !== null) { @@ -143,9 +176,11 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { useAppStore.getState().clearAgentLaunchConfig(session.cacheKey) return } - session.settleDeferredCommandFinishedStatusDrop() + session.settleDeferredCommandFinishedStatusDrop({ confirmedShell: true }) }, - onCommandFinishedUnavailable: session.settleDeferredCommandFinishedStatusDrop, + // Why wrapped: passed bare, a caller-supplied argument would be read as `options` and could + // reconcile on the unavailable path, which has no proof the agent exited. + onCommandFinishedUnavailable: () => session.settleDeferredCommandFinishedStatusDrop(), onVisibleForegroundSettled: (outcome) => { session.visibleForegroundSamplePending = false session.visibleForegroundSampleSettled = outcome !== 'inconclusive' @@ -195,10 +230,16 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { if (shouldDeferStatusDrop) { // Why: keep the concrete pane identity routable while the local process // check distinguishes a leaked nested-shell D from a genuine agent exit. + // Anchor the accepted-status ordinal once, here — not per rung of the confirm ladder, which + // can span seconds — so the gate tolerates churn across the whole window. + const armedAcceptedStatusSeq = entry?.acceptedStatusSeq session.deferredCommandFinishedStatusDrop = dropStatus + session.deferredConfirmedShellReconcile = () => + reconcileEndedProcessIfPaneQuiet(armedAcceptedStatusSeq) return } session.deferredCommandFinishedStatusDrop = null + session.deferredConfirmedShellReconcile = null dropStatus() } session.sampleVisiblePaneForegroundAgent = (forceRoutingConfirmation = false): void => { diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 95cde4a3979..756e91549fc 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -2343,6 +2343,15 @@ export const createAgentStatusSlice: StateCreator { onLegacyWorkerTerminalRecovery: () => noopUnsubscribe, getMigrationUnsupportedSnapshot: () => Promise.resolve([]), drop: () => {}, + reconcileEndedProcess: () => {}, dropByTabPrefix: () => {}, retirePaneAuthority: () => {}, restorePaneAuthority: () => {}, diff --git a/src/shared/agent-hook-listener-session-replacement.test.ts b/src/shared/agent-hook-listener-session-replacement.test.ts new file mode 100644 index 00000000000..918840aac57 --- /dev/null +++ b/src/shared/agent-hook-listener-session-replacement.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest' +import { + createHookListenerState, + normalizeHookPayload, + type HookListenerState +} from './agent-hook-listener' +import { makePaneKey } from './stable-pane-id' + +const LEAF_ID = '44444444-4444-4444-8444-444444444444' +const SESSION_A = 'session-a' +const SESSION_B = 'session-b' + +function claudeEvent( + state: HookListenerState, + paneKey: string, + payload: Record +): ReturnType { + return normalizeHookPayload(state, 'claude', { paneKey, payload }, 'production') +} + +/** Close a turn for `sessionId`, carrying whatever inventory the caller wants folded. */ +function stop( + state: HookListenerState, + paneKey: string, + sessionId: string, + extra: Record = {} +): ReturnType { + return claudeEvent(state, paneKey, { + hook_event_name: 'Stop', + session_id: sessionId, + ...extra + }) +} + +// A replacement session is the only evidence Orca gets for the exits Claude emits no terminating +// hook for (/clear, relaunch, resume). See PLAN-STA-4612 §4.1. +describe('Claude session replacement voids the replaced session claims', () => { + it('voids a session cron gate held by the previous session', () => { + const state = createHookListenerState() + const paneKey = makePaneKey('cron-gate', LEAF_ID) + + stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] }) + expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(true) + + const replaced = stop(state, paneKey, SESSION_B) + + expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(false) + expect(replaced?.payload.state).toBe('done') + }) + + it('voids a one-shot subagent the previous session was still running', () => { + const state = createHookListenerState() + const paneKey = makePaneKey('roster-void', LEAF_ID) + + claudeEvent(state, paneKey, { + hook_event_name: 'UserPromptSubmit', + session_id: SESSION_A, + prompt: 'go' + }) + claudeEvent(state, paneKey, { + hook_event_name: 'SubagentStart', + session_id: SESSION_A, + agent_id: 'achild-0000000000000001' + }) + expect(state.claudeSubagentRosterByPaneKey.get(paneKey)?.size).toBe(1) + + const replaced = stop(state, paneKey, SESSION_B) + + expect(state.claudeSubagentRosterByPaneKey.has(paneKey)).toBe(false) + expect(replaced?.payload.state).toBe('done') + expect(replaced?.payload.subagents ?? []).toEqual([]) + }) + + it('keeps a confirmed teammate across the replacement — a lead swap cannot end an in-process teammate', () => { + const state = createHookListenerState() + const paneKey = makePaneKey('teammate-kept', LEAF_ID) + + // Anchor the owning session on a LEAD event first. Subagent/teammate events branch out of + // normalizeClaudeEvent before the void ever runs, so without this the replacement below finds + // no previous owner, returns early, and the assertion certifies a guard it never exercised. + claudeEvent(state, paneKey, { + hook_event_name: 'UserPromptSubmit', + session_id: SESSION_A, + prompt: 'spin up the team' + }) + claudeEvent(state, paneKey, { + hook_event_name: 'SubagentStart', + session_id: SESSION_A, + agent_id: 'arev-0000000000000001', + agent_type: 'rev' + }) + claudeEvent(state, paneKey, { + hook_event_name: 'TeammateIdle', + session_id: SESSION_A, + teammate_name: 'rev' + }) + claudeEvent(state, paneKey, { + hook_event_name: 'SubagentStart', + session_id: SESSION_A, + agent_id: 'arev-0000000000000001', + agent_type: 'rev' + }) + const tracked = state.claudeSubagentRosterByPaneKey.get(paneKey)?.get('arev-0000000000000001') + expect(tracked?.confirmedTeammate).toBe(true) + expect(tracked?.state).toBe('working') + expect(state.claudeSessionOwnerByPaneKey.get(paneKey)).toBe(SESSION_A) + + stop(state, paneKey, SESSION_B) + + // The replacement really happened, so the surviving row below is the guard's doing. + expect(state.claudeSessionOwnerByPaneKey.get(paneKey)).toBe(SESSION_B) + + expect( + state.claudeSubagentRosterByPaneKey.get(paneKey)?.get('arev-0000000000000001')?.state + ).toBe('working') + }) + + it('KEEPS a running background-shell gate across the replacement', () => { + // /clear keeps the Claude process alive, so a background shell can outlive the conversation and + // the previous inventory is positive evidence it was running. Retiring it here would decide the + // open live-shell product question inside this change. See PLAN-STA-4612 §6.3. + const state = createHookListenerState() + const paneKey = makePaneKey('bg-gate-kept', LEAF_ID) + + stop(state, paneKey, SESSION_A, { + background_tasks: [{ type: 'bash', status: 'running', id: 'bash_1' }] + }) + expect(state.claudeRunningNonAgentTaskPaneKeys.has(paneKey)).toBe(true) + + const replaced = claudeEvent(state, paneKey, { + hook_event_name: 'PostToolUse', + session_id: SESSION_B + }) + + expect(state.claudeRunningNonAgentTaskPaneKeys.has(paneKey)).toBe(true) + expect(replaced?.payload.state).toBe('working') + }) + + it('voids nothing when the session id is unchanged', () => { + const state = createHookListenerState() + const paneKey = makePaneKey('same-session', LEAF_ID) + + stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] }) + const same = stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] }) + + expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(true) + expect(same?.payload.state).toBe('working') + }) + + it('voids nothing when a CHILD event carries a foreign session id', () => { + // Claude has no equivalent of the Codex child-session guard, so a child payload's own + // session_id reaches the same extraction path as the lead's. + const state = createHookListenerState() + const paneKey = makePaneKey('child-session', LEAF_ID) + + stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] }) + claudeEvent(state, paneKey, { + hook_event_name: 'PostToolUse', + session_id: 'child-session', + agent_id: 'achild-0000000000000009' + }) + + expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(true) + expect(state.claudeSessionOwnerByPaneKey.get(paneKey)).toBe(SESSION_A) + }) + + it('voids nothing when the event carries no session id', () => { + const state = createHookListenerState() + const paneKey = makePaneKey('no-session', LEAF_ID) + + stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] }) + stop(state, paneKey, '') + + expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(true) + expect(state.claudeSessionOwnerByPaneKey.get(paneKey)).toBe(SESSION_A) + }) + + it('does not anchor an ignored compact SessionStart', () => { + const state = createHookListenerState() + const paneKey = makePaneKey('compact-owner', LEAF_ID) + + stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] }) + expect( + claudeEvent(state, paneKey, { + hook_event_name: 'SessionStart', + session_id: SESSION_B, + source: 'compact' + }) + ).toBeNull() + + stop(state, paneKey, SESSION_B) + + expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(false) + }) + + it('leaves the lead-state record to the incoming fold rather than deleting it', () => { + const state = createHookListenerState() + const paneKey = makePaneKey('lead-untouched', LEAF_ID) + + stop(state, paneKey, SESSION_A) + claudeEvent(state, paneKey, { + hook_event_name: 'UserPromptSubmit', + session_id: SESSION_B, + prompt: 'new conversation' + }) + + expect(state.claudeLeadStateByPaneKey.get(paneKey)?.state).toBe('working') + }) +}) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index ccfabb603df..ecc0d986367 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -142,6 +142,10 @@ export type HookListenerState = { claudeRunningNonAgentTaskPaneKeys: Set /** Panes whose latest authoritative Claude cron inventory still has a scheduled job. */ claudeActiveSessionCronPaneKeys: Set + /** Claude `session_id` that last reported on the pane from a LEAD event. A different id means the + * conversation was replaced (/clear, relaunch, resume), so claims the old session owned are void + * even when no SessionStart arrives — the backstop for the exits that emit no terminating hook. */ + claudeSessionOwnerByPaneKey: Map /** Live thread-spawn children per Codex pane. */ codexSubagentRosterByPaneKey: Map /** Incremental parent/child rollout cursors for Codex collaboration v2. */ @@ -182,6 +186,7 @@ export function createHookListenerState(): HookListenerState { claudeUnconfirmedRestoredStatusPaneKeys: new Set(), claudeRunningNonAgentTaskPaneKeys: new Set(), claudeActiveSessionCronPaneKeys: new Set(), + claudeSessionOwnerByPaneKey: new Map(), codexSubagentRosterByPaneKey: new Map(), codexSubagentTranscriptByPaneKey: new Map(), codexLeadStateByPaneKey: new Map() @@ -199,11 +204,32 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) + state.claudeSessionOwnerByPaneKey.delete(paneKey) state.codexSubagentRosterByPaneKey.delete(paneKey) state.codexSubagentTranscriptByPaneKey.delete(paneKey) state.codexLeadStateByPaneKey.delete(paneKey) } +/** Does this pane still hold anything that can ASSERT a state — a stored row, or a Claude latch that + * `resolveClaudePaneState` would re-gate `working` from on the pane's next event? + * + * Deliberately lives next to `clearPaneCacheState` above and enumerates the claim-bearing subset of + * what that function deletes: the two must be edited together, and keeping them three lines apart in + * one file is what makes that obvious. Prompt/tool/transcript caches are excluded — they render a + * row, they never create one. */ +export function paneHasStateClaims(state: HookListenerState, paneKey: string): boolean { + return ( + state.lastStatusByPaneKey.has(paneKey) || + state.claudeSubagentRosterByPaneKey.has(paneKey) || + state.claudeLeadStateByPaneKey.has(paneKey) || + state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + state.claudeActiveSessionCronPaneKeys.has(paneKey) || + state.claudeSessionOwnerByPaneKey.has(paneKey) || + state.codexSubagentRosterByPaneKey.has(paneKey) || + state.codexLeadStateByPaneKey.has(paneKey) + ) +} + function movePaneScopedMapEntries( map: Map, fromPaneKey: string, @@ -246,6 +272,7 @@ export function movePaneCacheState( movePaneScopedSetEntries(state.claudeUnconfirmedRestoredStatusPaneKeys, fromPaneKey, toPaneKey) movePaneScopedSetEntries(state.claudeRunningNonAgentTaskPaneKeys, fromPaneKey, toPaneKey) movePaneScopedSetEntries(state.claudeActiveSessionCronPaneKeys, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.claudeSessionOwnerByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) @@ -291,6 +318,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.claudeUnconfirmedRestoredStatusPaneKeys.clear() state.claudeRunningNonAgentTaskPaneKeys.clear() state.claudeActiveSessionCronPaneKeys.clear() + state.claudeSessionOwnerByPaneKey.clear() state.codexSubagentRosterByPaneKey.clear() state.codexSubagentTranscriptByPaneKey.clear() state.codexLeadStateByPaneKey.clear() @@ -2631,6 +2659,9 @@ function updateClaudeRunningNonAgentTask( state: HookListenerState, paneKey: string, hasRunningNonAgentTask: boolean, + /** Lead-turn property. Pass `false` from any non-lead fold: an interrupt clears the gate even when + * the inventory positively reports a running shell, which is a live-shell judgement no new call + * site may inherit by copying this signature. */ interrupted: boolean ): void { if (hasRunningNonAgentTask && !interrupted) { @@ -2914,6 +2945,84 @@ function buildClaudeCachedLeadStatusPayload( }) } +/** Lead events that may re-anchor a pane's owning session. Allow-list, not a deny-list: a payload we + * can't attribute (unknown name, child event missing its agent_id) must void nothing. */ +const CLAUDE_SESSION_OWNER_EVENTS: ReadonlySet = new Set([ + 'SessionStart', + 'UserPromptSubmit', + 'Stop', + 'StopFailure', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest' +]) + +/** A pane whose `session_id` changed is running a different conversation, so claims the previous one + * owned are void — the hook-independent backstop for /clear, relaunch and resume, which emit no + * terminating hook (SessionEnd covers about a third of exit paths). + * + * Voids only what the replaced session provably owned. Deliberately NOT voided: + * - `claudeRunningNonAgentTaskPaneKeys`: a background shell is an OS process that survives /clear, + * and the previous inventory is positive evidence it was running. Only a fresh inventory or a + * certified process death may retire it. + * - `confirmedTeammate` roster rows: persistent in-process teammates a lead replacement can't end. + * - `claudeLeadStateByPaneKey`: the caller's own fold overwrites it anyway. */ +function voidClaimsOfReplacedClaudeSession( + state: HookListenerState, + eventName: unknown, + eventAgentId: string | undefined, + paneKey: string, + hookPayload: Record +): void { + if ( + eventAgentId !== undefined || + typeof eventName !== 'string' || + !CLAUDE_SESSION_OWNER_EVENTS.has(eventName) + ) { + return + } + // Compact/unknown SessionStart events are intentionally ignored by the status fold; do not let + // them advance the owner anchor or the next real lead event will miss the replacement. + if ( + eventName === 'SessionStart' && + hookPayload['source'] !== 'startup' && + hookPayload['source'] !== 'resume' && + hookPayload['source'] !== 'clear' + ) { + return + } + const sessionId = readString(hookPayload, 'session_id') + if (!sessionId) { + return + } + const previousOwner = state.claudeSessionOwnerByPaneKey.get(paneKey) + state.claudeSessionOwnerByPaneKey.set(paneKey, sessionId) + if (previousOwner === undefined || previousOwner === sessionId) { + return + } + // Why: a compact restart mints a SessionStart mid-turn under the same conversation; the existing + // handler already fails closed on non-idle sources, and this must not undercut it. No other + // allow-listed event carries a compact `trigger`, so SessionStart is the whole guard — if a + // compact event is ever added to CLAUDE_SESSION_OWNER_EVENTS, re-derive one for it deliberately. + if (eventName === 'SessionStart') { + return + } + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + if (!roster) { + return + } + for (const [id, tracked] of roster) { + if (tracked.confirmedTeammate !== true) { + roster.delete(id) + } + } + if (roster.size === 0) { + state.claudeSubagentRosterByPaneKey.delete(paneKey) + } +} + function normalizeClaudeEvent( state: HookListenerState, eventName: unknown, @@ -2929,6 +3038,7 @@ function normalizeClaudeEvent( ) { return normalizeClaudeSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) } + voidClaimsOfReplacedClaudeSession(state, eventName, eventAgentId, paneKey, hookPayload) if (eventName === 'SessionStart') { // Why: SessionStart is the only signal a resumed session emits before its first prompt // (STA-3386). Land it as a session-boundary 'done' row: 'working' would show a phantom diff --git a/src/shared/agent-status-observation.ts b/src/shared/agent-status-observation.ts index 0eeb60e4bb3..d0712d217c5 100644 --- a/src/shared/agent-status-observation.ts +++ b/src/shared/agent-status-observation.ts @@ -62,6 +62,17 @@ export type AgentStatusObservation = { * body, so a hook or OSC writer cannot declare its own provenance. */ export type WithAgentStatusObservation = { observation?: AgentStatusObservation } +/** Renderer-local count of ACCEPTED status writes this pane's row has taken. Incremented only by + * the store's accept branch, off the row it replaces, and carried through by every field-level + * rewrite — so it answers "did the pane report again?", which `updatedAt` cannot, because the + * accept rule admits equal timestamps. Lives on the row rather than in a side table so no + * teardown path can reset it out from under a reader (STA-4612). Never sent over IPC or + * persisted to last-status.json. + * + * Declared here beside the observation facet because both are per-write facets mixed into + * `AgentStatusEntry` rather than fields a reporter supplies. */ +export type AgentStatusRowFacets = WithAgentStatusObservation & { acceptedStatusSeq?: number } + // ─── THE ORDERING RULE ────────────────────────────────────────────────────── // `(authorityId, incarnation, revision)` is a total order ONLY within one authorityId. // A different authorityId means "incomparable", not "older" — the id is regenerated per diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 6df1c6568f7..714b47d4a80 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -3,7 +3,7 @@ // a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook. import type { AgentProviderSessionMetadata } from './agent-session-resume' -import type { WithAgentStatusObservation } from './agent-status-observation' +import type { AgentStatusRowFacets, WithAgentStatusObservation } from './agent-status-observation' import { normalizeInteractivePromptField, normalizeOptionalField, @@ -152,7 +152,7 @@ export type AgentStatusEntry = { * the transition may have been missed while no receiver was up, so freshness gates * treat the row as stale immediately. Cleared by any accepted live event. */ restoredUnconfirmed?: boolean -} & WithAgentStatusObservation +} & AgentStatusRowFacets export type MigrationUnsupportedPtyEntry = { ptyId: string From aa32871a61fba003bd24b24e0b75019724041741 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:23:16 -0700 Subject: [PATCH 10/12] Improve cmd j ranking with recency (#16281) * Track container-only tokens and tab focus for cmd+j ranking Previously ranked by whether any container-only matches existed (boolean); now counts tokens matching only containers for finer-grained ranking. Tab focus recency is now tracked explicitly so recent refocuses rank above stale worktree activity. Preserves worktree grouping by input order while applying focused-group MRU within each block. * fix(cmd-j): preserve duplicate recent tab occurrences * fix(cmd-j): preserve host scope during worktree purge * fix(cmd-j): scope repo purge for exact-id host twins * fix(cmd-j): scope ssh visit recency to local to survive restarts Boot hydration loads only local + runtime:* partitions, so routing ssh-qualified recency to ssh partitions strands it across restarts. - Keep ssh-qualified visit timestamps in local partition - Route runtime-qualified keys to their partition - Remove groupId from recent tab occurrence base (unstable on regroup) - Collapse bare and host-qualified timestamps, preserving max - Simplify repo pruning host-match logic - Add robustness: optional chaining, helper function * Scope focused tab recency by worktree to fix Cmd+J ranking Tab ids can be duplicated across worktrees; scoping recency keys to per-worktree prevents one worktree's MRU position from overwriting another's in Cmd+J. Scope worktree order blocks to (hostId, worktreeId) to keep same-id worktrees on different hosts separate. Also fix recency preservation during partial identity migrations and prune orphaned host keys on removal. --- src/main/ipc/remote-workspace.ts | 14 ++- .../profile-project-session-transfer.ts | 1 + .../profile-project-transfer-worktree-ids.ts | 9 +- .../profile-project-worktree-identity.ts | 19 +++- ...ence-cohort-and-identity-migration.test.ts | 13 +++ src/main/persistence-repo-lifecycle.test.ts | 26 +++++ .../session-owner-fields.ts | 11 +- .../tracking-repos/repo-worktree-pruning.ts | 17 ++- .../worktree-identity-migration.ts | 28 ++++- .../WorktreeJumpPalette.recent-tabs.test.tsx | 20 ++++ .../src/components/WorktreeJumpPalette.tsx | 83 +++++++++++--- .../active-worktree-focus-after-delete.ts | 5 +- .../workspace-cleanup-facet-row-caches.ts | 6 +- .../workspace-cleanup-facets.ts | 8 +- .../worktree-jump-palette-test-fixtures.ts | 30 +++++ .../hooks/remote-workspace-session-merge.ts | 29 ++++- .../hooks/remote-workspace-snapshot-apply.ts | 7 +- src/renderer/src/hooks/useIpcEvents.ts | 7 +- .../src/lib/cmd-j-section-leadership.test.ts | 2 +- .../src/lib/order-empty-query-worktrees.ts | 5 +- .../src/lib/palette-match/match-document.ts | 24 +++- .../src/lib/palette-match/palette-document.ts | 6 +- .../palette-match/palette-match-core.test.ts | 47 +++++++- .../src/lib/recent-workspace-tab-rows.test.ts | 104 ++++++++++++++++-- .../src/lib/recent-workspace-tab-rows.ts | 60 ++++++++-- .../lib/workspace-session-host-persistence.ts | 7 ++ .../lib/workspace-session-host-split.test.ts | 26 +++++ .../src/lib/workspace-session-host-split.ts | 40 ++++++- .../lib/workspace-tab-agent-snippet-match.ts | 2 +- .../lib/workspace-tab-palette-results.test.ts | 59 +++++++++- .../src/lib/workspace-tab-palette-results.ts | 10 +- .../lib/worktree-palette-task-url-result.ts | 2 +- .../src/lib/worktree-visit-recency.test.ts | 44 ++++++++ .../src/lib/worktree-visit-recency.ts | 103 +++++++++++++++++ .../purge-stale-runtime-host-state.test.ts | 9 ++ .../repos-host-identity-routing.test.ts | 25 +++++ src/renderer/src/store/slices/repos.ts | 26 ++++- .../src/store/slices/workspace-cleanup.ts | 20 +++- .../slices/worktree-delete-state-types.ts | 15 +++ .../src/store/slices/worktree-helpers.ts | 30 ++--- .../src/store/slices/worktree-purge-target.ts | 7 ++ ...orktrees-workspace-selection-state.test.ts | 34 ++++++ .../listing/fetched-worktree-merge.ts | 7 +- .../session/worktree-identity-rename-state.ts | 20 +++- .../session/worktree-slice-lookups.ts | 11 +- .../session/worktree-visit-recency.ts | 46 ++++++-- .../purge-stale-runtime-host-state.ts | 80 +++++++++++++- .../teardown/remove-worktree-store-cleanup.ts | 18 +-- .../removed-worktree-renderer-teardown.ts | 2 +- .../teardown/worktree-purge-state.ts | 17 ++- .../remote-workspace-session-projection.ts | 33 +++++- src/shared/workspace-session-schema.ts | 2 + src/shared/workspace-session-state-types.ts | 4 +- ...workspace-session-tab-focus-schema.test.ts | 53 +++++++++ .../worktree/host-qualified-identity.ts | 12 +- 55 files changed, 1199 insertions(+), 146 deletions(-) create mode 100644 src/renderer/src/lib/worktree-visit-recency.test.ts create mode 100644 src/renderer/src/lib/worktree-visit-recency.ts create mode 100644 src/renderer/src/store/slices/worktree-delete-state-types.ts create mode 100644 src/renderer/src/store/slices/worktree-purge-target.ts create mode 100644 src/shared/workspace-session-tab-focus-schema.test.ts diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 527a001bb67..2b8966c3297 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -9,6 +9,7 @@ import type { } from '../../shared/remote-workspace-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { parseExecutionHostId } from '../../shared/execution-host' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' import { CLIENT_ID } from './remote-workspace-client-identity' @@ -55,7 +56,15 @@ function getExplicitHydratedTargetIds(value: unknown): Set | null { return new Set(value) } -function targetForWorktree(store: Store, worktreeId: string): string | null { +function targetForWorktree( + store: Store, + worktreeId: string, + executionHostId?: string +): string | null { + const parsedHostId = parseExecutionHostId(executionHostId) + if (parsedHostId?.kind === 'ssh') { + return parsedHostId.targetId + } const repoId = getRepoIdFromWorktreeId(worktreeId) return store.getRepo(repoId)?.connectionId ?? null } @@ -66,7 +75,8 @@ function exportSessionForTarget( session: WorkspaceSessionState ): RemoteWorkspaceSession { return exportRemoteWorkspaceSession(session, { - isTargetWorktree: (worktreeId) => targetForWorktree(store, worktreeId) === targetId + isTargetWorktree: (worktreeId, executionHostId) => + targetForWorktree(store, worktreeId, executionHostId) === targetId }) } diff --git a/src/main/orca-profiles/profile-project-session-transfer.ts b/src/main/orca-profiles/profile-project-session-transfer.ts index d5884096bd9..da55794b551 100644 --- a/src/main/orca-profiles/profile-project-session-transfer.ts +++ b/src/main/orca-profiles/profile-project-session-transfer.ts @@ -39,6 +39,7 @@ function hasTransferredSessionState(session: WorkspaceSessionState): boolean { Object.keys(session.browserTabsByWorktree ?? {}).length > 0 || Object.keys(session.unifiedTabs ?? {}).length > 0 || Object.keys(session.tabGroups ?? {}).length > 0 || + Object.keys(session.lastVisitedAtByWorktreeId ?? {}).length > 0 || Object.keys(session.terminalTopologyRevisionByRepoId ?? {}).length > 0 ) } diff --git a/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts b/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts index 51d87cfc668..485861d748d 100644 --- a/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts +++ b/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts @@ -2,6 +2,10 @@ import type { PersistedState } from '../../shared/persisted-state-types' import { parseWorkspaceKey } from '../../shared/workspace-scope' import type { TransferProfileState } from './profile-project-state-file' import { isRepoWorktreeId } from './profile-project-worktree-identity' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../shared/worktree/host-qualified-identity' export function collectTransferWorktreeIds( state: TransferProfileState, @@ -51,8 +55,9 @@ function collectSessionWorktreeIds( } const addOwnerKeys = (record: Record | undefined): void => { for (const key of Object.keys(record ?? {})) { - if (isRepoWorktreeId(repoId, key)) { - ids.add(key) + const rawKey = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key + if (isRepoWorktreeId(repoId, rawKey)) { + ids.add(rawKey) } const parsed = parseWorkspaceKey(key) if (parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId)) { diff --git a/src/main/orca-profiles/profile-project-worktree-identity.ts b/src/main/orca-profiles/profile-project-worktree-identity.ts index cdc8d9182d2..1586a0e0120 100644 --- a/src/main/orca-profiles/profile-project-worktree-identity.ts +++ b/src/main/orca-profiles/profile-project-worktree-identity.ts @@ -4,6 +4,10 @@ import type { WorkspaceKey } from '../../shared/folder-workspace-types' import type { Repo } from '../../shared/repo-types' import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../shared/worktree/host-qualified-identity' export function repoPhysicalKey( repo: Pick @@ -46,8 +50,14 @@ export function rekeyOwnerKey( newRepoId: string, ownerKey: string ): string | null { - if (isRepoWorktreeId(oldRepoId, ownerKey)) { - return rekeyWorktreeId(oldRepoId, newRepoId, ownerKey) + const rawOwnerKey = isWorktreeHostIdentity(ownerKey) + ? getWorktreeIdFromHostIdentity(ownerKey) + : ownerKey + if (isRepoWorktreeId(oldRepoId, rawOwnerKey)) { + const rekeyed = rekeyWorktreeId(oldRepoId, newRepoId, rawOwnerKey) + return isWorktreeHostIdentity(ownerKey) + ? `${ownerKey.slice(0, ownerKey.length - rawOwnerKey.length)}${rekeyed}` + : rekeyed } const parsed = parseWorkspaceKey(ownerKey) if (parsed?.type === 'worktree' && isRepoWorktreeId(oldRepoId, parsed.worktreeId)) { @@ -57,7 +67,10 @@ export function rekeyOwnerKey( } export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean { - if (isRepoWorktreeId(repoId, ownerKey)) { + const rawOwnerKey = isWorktreeHostIdentity(ownerKey) + ? getWorktreeIdFromHostIdentity(ownerKey) + : ownerKey + if (isRepoWorktreeId(repoId, rawOwnerKey)) { return true } const parsed = parseWorkspaceKey(ownerKey) diff --git a/src/main/persistence-cohort-and-identity-migration.test.ts b/src/main/persistence-cohort-and-identity-migration.test.ts index 9a8edc4a964..9d931a1bbd4 100644 --- a/src/main/persistence-cohort-and-identity-migration.test.ts +++ b/src/main/persistence-cohort-and-identity-migration.test.ts @@ -387,6 +387,19 @@ describe('Store.migrateWorktreeIdentity', () => { expect(store.getWorktreeMeta(NEWER)?.priorWorktreeIds).toEqual([OLD, NEW]) }) + it('keeps the newest visit timestamp when a partial migration left both identities', async () => { + const store = await createStore() + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { [OLD]: 100, [NEW]: 900 } + }) + + store.migrateWorktreeIdentity(OLD, NEW) + + expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.[OLD]).toBeUndefined() + expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.[NEW]).toBe(900) + }) + it('is a no-op when the ids match', async () => { const store = await createStore() store.setWorktreeMeta(OLD, { displayName: 'Cunner' }) diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index 3266e92bb7f..e27287c147e 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -467,6 +467,32 @@ describe('Store', () => { ).toBe(222) }) + it('removeProjectForHost on local prunes the unqualified host-identity visit key', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'shared', path: '/repo' })) + store.addRepo( + makeRepo({ + id: 'shared', + path: '/repo', + connectionId: 'ssh-a', + executionHostId: 'ssh:ssh-a' + }) + ) + // No meta for this worktree, so only the unqualified host-identity key names it. The host split + // parks that canonical unknown-host form in the local partition; leaving it behind after a local + // removal re-materializes an orphaned workspace next launch. + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { '|shared::/repo/wt-orphan': 111 } + }) + + store.removeProjectForHost('shared', 'local') + + expect( + store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['|shared::/repo/wt-orphan'] + ).toBeUndefined() + }) + it('removeProjectForHost prunes only the removed host when a third host also shares the owner key', async () => { const store = await createStore() // Same repo id + path on local and two SSH hosts, so the owner key diff --git a/src/main/persistence/restoring-sessions/session-owner-fields.ts b/src/main/persistence/restoring-sessions/session-owner-fields.ts index ee06ada964b..c18d50ae612 100644 --- a/src/main/persistence/restoring-sessions/session-owner-fields.ts +++ b/src/main/persistence/restoring-sessions/session-owner-fields.ts @@ -1,6 +1,10 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' export function createMinimalPersistedTerminalTab(args: { worktreeId: string @@ -93,7 +97,12 @@ export function deleteOwnerKeyedSessionFields( delete next.activeGroupIdByWorktree[ownerKey] } if (next.lastVisitedAtByWorktreeId) { - delete next.lastVisitedAtByWorktreeId[ownerKey] + for (const key of Object.keys(next.lastVisitedAtByWorktreeId)) { + const rawId = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key + if (key === ownerKey || rawId === ownerKey) { + delete next.lastVisitedAtByWorktreeId[key] + } + } } if (next.defaultTerminalTabsAppliedByWorktreeId) { delete next.defaultTerminalTabsAppliedByWorktreeId[ownerKey] diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index f12da044c15..50c4fe5f745 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -3,6 +3,10 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/e import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' export function pruneWorktreeStateForRepo( state: StoreOwnedPersistedState, @@ -36,7 +40,18 @@ export function pruneWorktreeStateForRepo( const ownerKeysToPrune = new Set() const collectPrefixedKeys = (keys: Iterable): void => { for (const key of keys) { - if (key.startsWith(prefix)) { + const rawKey = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key + const keyHost = isWorktreeHostIdentity(key) ? key.slice(0, key.indexOf('|')) : null + // Why: bare keys are scoped by the per-partition gating below; only host-qualified + // keys can sit in another host's partition and need host-matching at collection time. + // Why the empty-host arm: the host split parks the canonical unknown-host form (`|`) in + // the local partition, so a local prune must claim it or the orphan restores next launch. + const belongsToPrunedHost = + hostId === null || + keyHost === null || + keyHost === hostId || + (keyHost === '' && hostId === LOCAL_EXECUTION_HOST_ID) + if (belongsToPrunedHost && rawKey.startsWith(prefix)) { ownerKeysToPrune.add(key) } } diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index f8256c0c4fe..a9d3397aa7c 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -1,6 +1,10 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' /** @@ -94,7 +98,29 @@ export function migrateWorktreeIdentity( moveSessionKey(session.tabGroups, (groups) => groups.map(withNewWorktreeId)) || sessionChanged sessionChanged = moveSessionKey(session.tabGroupLayouts) || sessionChanged sessionChanged = moveSessionKey(session.activeGroupIdByWorktree) || sessionChanged - sessionChanged = moveSessionKey(session.lastVisitedAtByWorktreeId) || sessionChanged + if (session.lastVisitedAtByWorktreeId) { + const nextRecency = { ...session.lastVisitedAtByWorktreeId } + let recencyChanged = false + for (const [key, value] of Object.entries(session.lastVisitedAtByWorktreeId)) { + const rawId = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key + if (rawId !== oldWorktreeId) { + continue + } + const nextKey = isWorktreeHostIdentity(key) + ? `${key.slice(0, key.length - rawId.length)}${newWorktreeId}` + : newWorktreeId + // Why max: a partial migration leaves both identities behind; taking the older one would + // regress Cmd+J recency after restart. + const existing = nextRecency[nextKey] + nextRecency[nextKey] = existing === undefined ? value : Math.max(existing, value) + delete nextRecency[key] + recencyChanged = true + } + if (recencyChanged) { + session.lastVisitedAtByWorktreeId = nextRecency + sessionChanged = true + } + } sessionChanged = moveSessionKey(session.defaultTerminalTabsAppliedByWorktreeId) || sessionChanged if (session.activeWorktreeIdsOnShutdown?.includes(oldWorktreeId)) { diff --git a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx index 990d9219627..9acf590819f 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx @@ -12,6 +12,7 @@ import { makePaneKey } from '../../../shared/stable-pane-id' import { LEAF_ID, makeAgentEntry, + makeDuplicateRecentTabState, makeGroup, makeManyTabState, makeRecentTabState, @@ -229,6 +230,25 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { expect(testContainer.textContent).toContain('Recent Worktrees') }) + it('keeps duplicate persisted tab ids as separate recent rows and digit targets', async () => { + await renderPalette(makeDuplicateRecentTabState()) + + expect( + getRenderedRowIds().filter( + (id) => id === 'workspace-tab:tab-duplicate' || id.includes(':workspace-tab:tab-duplicate') + ) + ).toEqual(['workspace-tab:tab-duplicate', 'palette-dup:1:workspace-tab:tab-duplicate']) + + await act(async () => { + emitCmdJRowIndexJump(1) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-duplicate', worktreeId: 'wt-beta' }) + ) + }) + it('caps the recent section so the worktree header stays above the fold', async () => { await renderPalette(makeManyTabState(12)) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 9a54ed398c7..0d69d1a9a68 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -350,9 +350,56 @@ function isCurrentOpenTabItem(item: OpenTabPaletteItem): boolean { return item.type === 'browser-page' ? item.result.isCurrentPage : item.result.isCurrentTab } +/** Not the command id: two hosts — or a duplicate snapshot — can publish the same tab id. */ +function getRecentTabOccurrenceBase(item: OpenTabPaletteItem): string { + if (item.type === 'browser-page') { + const result = item.result + return JSON.stringify([ + item.type, + item.id, + result.executionHostId ?? '', + result.worktreeId, + result.workspaceId, + result.pageId + ]) + } + if (item.type === 'simulator-tab') { + const result = item.result + // Why no groupId: it changes when a tab is regrouped mid-open, and the frozen + // order must keep resolving the row; tabId already identifies it within a host. + return JSON.stringify([ + item.type, + item.id, + result.executionHostId ?? '', + result.worktreeId, + result.tabId + ]) + } + const result = item.result + return JSON.stringify([ + item.type, + item.id, + result.executionHostId ?? '', + result.worktreeId, + result.tabId, + result.entityId + ]) +} + +function buildRecentTabOccurrenceIds(items: readonly OpenTabPaletteItem[]): string[] { + const nextOrdinalByBase = new Map() + return items.map((item) => { + const base = getRecentTabOccurrenceBase(item) + const ordinal = nextOrdinalByBase.get(base) ?? 0 + nextOrdinalByBase.set(base, ordinal + 1) + return `recent-tab:${base}:${ordinal}` + }) +} + /** An open tab's recent-section row plus the inputs inclusion needs. */ type OpenTabRecentRow = { item: OpenTabPaletteItem + occurrenceId: string worktree: Worktree row: RecentWorkspaceTabRow } @@ -1419,17 +1466,22 @@ function WorktreeJumpPaletteContent({ // once inclusion would drop it (a current tab that quiets down), or the pip blanks mid-open. const openTabRecentRows = useMemo(() => { const entries: OpenTabRecentRow[] = [] - for (const item of openTabItems) { + const occurrenceIds = buildRecentTabOccurrenceIds(openTabItems) + for (const [index, item] of openTabItems.entries()) { const worktree = resolveWorktree(item.result.worktreeId, item.result.executionHostId) if (!worktree) { continue } + const occurrenceId = occurrenceIds[index] entries.push({ item, + occurrenceId, worktree, row: { id: item.id, + occurrenceId, worktreeId: worktree.id, + worktreeHostId: worktree.hostId, unifiedTabId: item.type === 'browser-page' ? null : item.result.tabId, terminalTab: item.type === 'workspace-tab' && item.result.contentType === 'terminal' @@ -1442,8 +1494,8 @@ function WorktreeJumpPaletteContent({ return entries }, [openTabItems, resolveWorktree, terminalTabsById]) - const recentTabRowById = useMemo( - () => new Map(openTabRecentRows.map(({ row }) => [row.id, row])), + const recentTabRowByItem = useMemo( + () => new Map(openTabRecentRows.map(({ item, row }) => [item, row])), [openTabRecentRows] ) @@ -1558,15 +1610,16 @@ function WorktreeJumpPaletteContent({ visible ]) - // Why: walk the frozen order rather than re-sorting the tab list — the frozen ids are the ranking, - // so agent churn never reshuffles rows under the cursor. Cap after resolving, not before: a chip - // applied mid-open narrows `openTabItems`, and capping first would leave the section empty. + // Why walk the frozen order, and cap after resolving: agent churn must not reshuffle rows, and a + // mid-open chip narrows `openTabItems` — capping first would leave the section empty. const recentTabItems = useMemo(() => { - const itemById = new Map(openTabItems.map((item) => [item.id, item])) + const itemByOccurrenceId = new Map( + openTabRecentRows.map(({ occurrenceId, item }) => [occurrenceId, item]) + ) return recentTabOrder - .flatMap((id) => itemById.get(id) ?? []) + .flatMap((occurrenceId) => itemByOccurrenceId.get(occurrenceId) ?? []) .slice(0, EMPTY_QUERY_RECENT_TAB_CAP) - }, [openTabItems, recentTabOrder]) + }, [openTabRecentRows, recentTabOrder]) const settingsResults = useMemo( () => buildCmdJSettingsResults(settingsSections), @@ -1873,10 +1926,10 @@ function WorktreeJumpPaletteContent({ ]) // Why: badges number the snapshotted recent rows only — ⌘N is meaningless on a typed query. - const recentTabShortcutIndexById = useMemo( + const recentTabShortcutIndexByItem = useMemo( () => new Map( - hasQuery ? [] : paletteSections.visibleOpenTabItems.map((item, index) => [item.id, index]) + hasQuery ? [] : paletteSections.visibleOpenTabItems.map((item, index) => [item, index]) ), [hasQuery, paletteSections] ) @@ -3450,7 +3503,7 @@ function WorktreeJumpPaletteContent({ ) // Why regardless of query: a searched-for tab is exactly when you need to know it's // still working — the map covers every open tab, not just the recent section. - const recentRow = recentTabRowById.get(entry.id) ?? null + const recentRow = recentTabRowByItem.get(entry) ?? null return ( )} @@ -3594,7 +3647,7 @@ function WorktreeJumpPaletteContent({ )} @@ -3672,7 +3725,7 @@ function WorktreeJumpPaletteContent({ )} diff --git a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts index 1993e044f4b..214b6c2c72c 100644 --- a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts +++ b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts @@ -4,6 +4,7 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' +import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' type AppStoreState = ReturnType @@ -56,7 +57,9 @@ function pickNextWorktreeIdAfterDelete( if (others.length > 0) { const lastVisited = state.lastVisitedAtByWorktreeId const [mostRecent] = [...others].sort( - (a, b) => (lastVisited[b.id] ?? 0) - (lastVisited[a.id] ?? 0) + (a, b) => + (getWorktreeVisitTimestamp(lastVisited, b) ?? 0) - + (getWorktreeVisitTimestamp(lastVisited, a) ?? 0) ) return mostRecent.id } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts index 07f1c041ab9..2ac23e11ff8 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts @@ -17,6 +17,7 @@ import { getWorkspaceCleanupCandidateIdentity, getWorkspaceCleanupHostIdentity } from './workspace-cleanup-host-identity' +import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency' /** * Streaming ticks replace only the candidate objects they touched, so all @@ -105,7 +106,10 @@ export function computeWorkspaceCleanupFacetList(args: { const sizeBytes = sources.sizeBytesByWorktreeId.get(hostIdentity) ?? sources.sizeBytesByWorktreeId.get(candidate.worktreeId) - const lastVisitedAt = sources.lastVisitedAtByWorktreeId[candidate.worktreeId] + const lastVisitedAt = getWorktreeVisitTimestamp(sources.lastVisitedAtByWorktreeId, { + id: candidate.worktreeId, + hostId: getWorkspaceCleanupCandidateHostId(candidate) + }) const agentState = sources.liveAgentStatusByWorktreeId.get(candidate.worktreeId) const review = sources.reviewInfoByWorktreeId.get(hostIdentity) ?? diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts index c52a9a020e6..8928b8b2b49 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts @@ -22,6 +22,7 @@ import { getWorkspaceCleanupHostIdentity, type WorkspaceCleanupWorktreeFacts } from './workspace-cleanup-host-identity' +import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency' export type { WorkspaceCleanupWorktreeFacts } from './workspace-cleanup-host-identity' export type WorkspaceCleanupFacetSources = { @@ -128,7 +129,12 @@ export function buildWorkspaceCleanupFacets( isSelectable: canSelectWorkspaceCleanupCandidate(candidate), lastActivityAt: candidate.lastActivityAt, createdAt: toFiniteOrNull(worktree?.createdAt ?? candidate.createdAt), - lastVisitedAt: toFiniteOrNull(sources.lastVisitedAtByWorktreeId?.[candidate.worktreeId]), + lastVisitedAt: toFiniteOrNull( + getWorktreeVisitTimestamp(sources.lastVisitedAtByWorktreeId, { + id: candidate.worktreeId, + hostId: worktree?.hostId ?? getWorkspaceCleanupCandidateHostId(candidate) + }) + ), sizeBytes: toFiniteOrNull( sources.sizeBytesByWorktreeId?.get(hostIdentity) ?? sources.sizeBytesByWorktreeId?.get(candidate.worktreeId) diff --git a/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts b/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts index 5dfbcdaf6a8..19c97ada254 100644 --- a/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts +++ b/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts @@ -136,6 +136,36 @@ export function makeRecentTabState(overrides: Partial = {}): Partial { + const alpha = makeWorktree('wt-alpha', 'Alpha workspace', { hostId: 'ssh:alpha' }) + const beta = makeWorktree('wt-beta', 'Beta workspace', { hostId: 'ssh:beta' }) + return { + worktreesByRepo: { 'repo-1': [alpha, beta] }, + showSleepingWorkspaces: true, + ptyIdsByTabId: { + 'term-alpha': ['pty-term-alpha'], + 'term-beta': ['pty-term-beta'] + }, + tabsByWorktree: { + 'wt-alpha': [makeTerminalTab('term-alpha', 'wt-alpha', 'Alpha duplicate')], + 'wt-beta': [makeTerminalTab('term-beta', 'wt-beta', 'Beta duplicate')] + }, + unifiedTabsByWorktree: { + 'wt-alpha': [makeUnifiedTab('tab-duplicate', 'wt-alpha', 'term-alpha', 'Alpha duplicate')], + 'wt-beta': [makeUnifiedTab('tab-duplicate', 'wt-beta', 'term-beta', 'Beta duplicate')] + }, + groupsByWorktree: { + 'wt-alpha': [makeGroup('wt-alpha', ['tab-duplicate'])], + 'wt-beta': [makeGroup('wt-beta', ['tab-duplicate'])] + }, + activeGroupIdByWorktree: { + 'wt-alpha': 'group-wt-alpha', + 'wt-beta': 'group-wt-beta' + } + } +} + /** One tab-heavy worktree plus `count` bare ones, so both sections overflow their caps. */ export function makeManyTabState(count: number): Partial { const ids = Array.from({ length: count }, (_, index) => `${index}`) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts index 4a2b087817f..731b1412068 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -1,7 +1,12 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { ExecutionHostId } from '../../../shared/execution-host' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' import { splitWorktreeId } from '../../../shared/worktree/id' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' import type { AppState } from '../store/types' function preserveNewerLocalTerminalFields(remote: TerminalTab, local: TerminalTab): TerminalTab { @@ -20,7 +25,8 @@ export function mergeDirectSshRemoteWorkspaceSession( remote: WorkspaceSessionState, replaceWorktreeIds: ReadonlySet, liveTabsByWorktree: AppState['tabsByWorktree'], - preserveLocalTerminalTabIds: ReadonlySet + preserveLocalTerminalTabIds: ReadonlySet, + replaceExecutionHostId?: ExecutionHostId ): WorkspaceSessionState { const currentTabsById = new Map( [...replaceWorktreeIds] @@ -146,6 +152,25 @@ export function mergeDirectSshRemoteWorkspaceSession( Object.fromEntries( Object.entries(record ?? {}).filter(([worktreeId]) => !replaceWorktreeIds.has(worktreeId)) ) + const omitTargetVisitRecency = ( + record: Record | undefined + ): Record => + Object.fromEntries( + Object.entries(record ?? {}).filter(([key]) => { + const worktreeId = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key + if (!replaceWorktreeIds.has(worktreeId)) { + return true + } + // A direct SSH snapshot replaces one host's row. Legacy bare keys + // remain because they may be the only recency evidence for a sibling + // host; a qualified key is removed only for the target host. + return Boolean( + replaceExecutionHostId && + (!isWorktreeHostIdentity(key) || + key.slice(0, key.indexOf('|')) !== replaceExecutionHostId) + ) + }) + ) const terminalLayoutsByTabId = { ...Object.fromEntries( Object.entries(current.terminalLayoutsByTabId).filter( @@ -226,7 +251,7 @@ export function mergeDirectSshRemoteWorkspaceSession( ) }, lastVisitedAtByWorktreeId: { - ...omitTargetWorktrees(current.lastVisitedAtByWorktreeId), + ...omitTargetVisitRecency(current.lastVisitedAtByWorktreeId), ...remote.lastVisitedAtByWorktreeId }, defaultTerminalTabsAppliedByWorktreeId: { diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts index 1ffad891d89..16929fd6379 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -2,6 +2,7 @@ import type { StoreApi } from 'zustand' import type { RemoteWorkspaceSnapshot } from '../../../shared/remote-workspace-types' import { importRemoteWorkspaceSession } from '../../../shared/remote-workspace-session-projection' import type { DirectSshAuthority } from '../../../shared/ssh-types' +import { toSshExecutionHostId } from '../../../shared/execution-host' import { translate } from '@/i18n/i18n' import { buildWorkspaceSessionPayload } from '../lib/workspace-session' import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope' @@ -109,14 +110,16 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ const state = store.getState() const worktreeIds = exactTargetWorktreeIds(state, authority) const remoteSession = importRemoteWorkspaceSession(snapshot.session, { - resolveWorktreeId: uniqueWorktreeIdByPath(worktreeIds) + resolveWorktreeId: uniqueWorktreeIdByPath(worktreeIds), + executionHostId: toSshExecutionHostId(authority.targetId) }) const merged = mergeDirectSshRemoteWorkspaceSession( buildWorkspaceSessionPayload(state), remoteSession, worktreeIds, state.tabsByWorktree, - currentRecoveryTabIds(state, authority, worktreeIds) + currentRecoveryTabIds(state, authority, worktreeIds), + toSshExecutionHostId(authority.targetId) ) if (!isArrivalCurrent(authority.targetId, arrival) || !isPreparationTokenCurrent(token)) { return diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index f4700df44b3..b04c3cd92de 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -924,7 +924,12 @@ export function useIpcEvents(): void { `[worktree-purge] diff-based purge removing state for ${removed.length} worktree(s):`, removed ) - afterState.purgeWorktreeTerminalState(removed) + const purgeHostId = + options?.executionHostId ?? + (options?.forceLocalOwner ? LOCAL_EXECUTION_HOST_ID : undefined) + afterState.purgeWorktreeTerminalState( + purgeHostId ? removed.map((id) => ({ id, hostId: purgeHostId })) : removed + ) afterState.removeWorkspaceSpaceWorktrees(removed) } } diff --git a/src/renderer/src/lib/cmd-j-section-leadership.test.ts b/src/renderer/src/lib/cmd-j-section-leadership.test.ts index 620f32869a5..410bf676e0d 100644 --- a/src/renderer/src/lib/cmd-j-section-leadership.test.ts +++ b/src/renderer/src/lib/cmd-j-section-leadership.test.ts @@ -12,7 +12,7 @@ import type { PaletteDocumentRank } from './palette-match/palette-document' function rank(overrides: Partial = {}): PaletteDocumentRank { return { exactIntent: 1, - matchedDirectField: 0, + containerOnlyTokenCount: 0, wholeQuery: 3, worstQuality: 5, usesSupportingEvidence: 0, diff --git a/src/renderer/src/lib/order-empty-query-worktrees.ts b/src/renderer/src/lib/order-empty-query-worktrees.ts index 08f31534145..54a116d73ce 100644 --- a/src/renderer/src/lib/order-empty-query-worktrees.ts +++ b/src/renderer/src/lib/order-empty-query-worktrees.ts @@ -2,6 +2,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import type { Worktree } from '../../../shared/worktree/types' import { isPaletteCurrentWorktree } from './palette-repo-resolution' import { compareWorktreeDisplayName } from './worktree-display-name-order' +import { getWorktreeVisitTimestamp } from './worktree-visit-recency' export type OrderEmptyQueryInputs = { visibleWorktrees: readonly Worktree[] @@ -51,8 +52,8 @@ export function orderEmptyQueryWorktrees(inputs: OrderEmptyQueryInputs): OrderEm // visited SSH worktree below the fold — the exact bug the feature // fixes. Compare presence first, then value within each tier. const sorted = [...switchable].sort((a, b) => { - const aVisited = lastVisitedAtByWorktreeId[a.id] - const bVisited = lastVisitedAtByWorktreeId[b.id] + const aVisited = getWorktreeVisitTimestamp(lastVisitedAtByWorktreeId, a) + const bVisited = getWorktreeVisitTimestamp(lastVisitedAtByWorktreeId, b) if (aVisited != null && bVisited != null) { if (bVisited !== aVisited) { return bVisited - aVisited diff --git a/src/renderer/src/lib/palette-match/match-document.ts b/src/renderer/src/lib/palette-match/match-document.ts index 9d19fe38220..7b2363c1e16 100644 --- a/src/renderer/src/lib/palette-match/match-document.ts +++ b/src/renderer/src/lib/palette-match/match-document.ts @@ -163,8 +163,10 @@ function rankAssignments(args: { let worstQuality: PaletteMatchQuality = 'field-exact' let fuzzyTokenCount = 0 const fields = new Set() - // Derive from field metadata only: an evidence field can itself be a container. - let hasDirectHit = false + let containerOnlyTokenCount = 0 + let tokenIndex = -1 + let tokenHasDirectField = false + let matchedTokenCount = 0 for (const assignment of args.assignments) { if (paletteMatchQualityRank(assignment.quality) > paletteMatchQualityRank(worstQuality)) { @@ -174,20 +176,32 @@ function rankAssignments(args: { fuzzyTokenCount += 1 } fields.add(assignment.fieldId) + if (assignment.tokenIndex !== tokenIndex) { + if (tokenIndex !== -1 && !tokenHasDirectField) { + containerOnlyTokenCount += 1 + } + tokenIndex = assignment.tokenIndex + tokenHasDirectField = false + matchedTokenCount += 1 + } const field = args.document.fieldById.get(assignment.fieldId) if (field && !field.isContainer) { - hasDirectHit = true + tokenHasDirectField = true } } - const isContainerOnly = !hasDirectHit + if (tokenIndex !== -1 && !tokenHasDirectField) { + containerOnlyTokenCount += 1 + } + const isContainerOnly = + containerOnlyTokenCount > 0 && containerOnlyTokenCount === matchedTokenCount return { worstQuality, isContainerOnly, rank: { exactIntent: args.exactIntent ? 0 : 1, - matchedDirectField: isContainerOnly ? 1 : 0, + containerOnlyTokenCount, wholeQuery: args.wholeQuery, worstQuality: paletteMatchQualityRank(worstQuality), usesSupportingEvidence: args.usesEvidence ? 1 : 0, diff --git a/src/renderer/src/lib/palette-match/palette-document.ts b/src/renderer/src/lib/palette-match/palette-document.ts index 2400921f6be..0934ff2cf41 100644 --- a/src/renderer/src/lib/palette-match/palette-document.ts +++ b/src/renderer/src/lib/palette-match/palette-document.ts @@ -129,8 +129,8 @@ export type PaletteSupportingEvidence = { export type PaletteDocumentRank = { /** 0 when a recognized exact intent (such as a task URL) produced this row. */ exactIntent: number - /** 0 when query matched at least one direct field; 1 when all matched tokens are container-only. */ - matchedDirectField: number + /** Tokens whose chosen assignment only matched container fields. */ + containerOnlyTokenCount: number /** 0 equality, 1 prefix, 2 word boundary, 3 none — whole query in visible text. */ wholeQuery: number worstQuality: number @@ -150,7 +150,7 @@ export type PaletteDocumentMatch = { const RANK_KEYS: readonly (keyof PaletteDocumentRank)[] = [ 'exactIntent', - 'matchedDirectField', + 'containerOnlyTokenCount', 'wholeQuery', 'worstQuality', 'usesSupportingEvidence', diff --git a/src/renderer/src/lib/palette-match/palette-match-core.test.ts b/src/renderer/src/lib/palette-match/palette-match-core.test.ts index 8862165bc79..de7bd7696c7 100644 --- a/src/renderer/src/lib/palette-match/palette-match-core.test.ts +++ b/src/renderer/src/lib/palette-match/palette-match-core.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest' import { matchPaletteDocument } from './match-document' import { mapNormalizedRange, normalizePaletteText } from './normalized-text' import { preparePaletteQuery, PALETTE_QUERY_MAX_TOKENS } from './palette-query' -import { buildPaletteDocument, type PaletteDocumentInput } from './palette-document' +import { + buildPaletteDocument, + comparePaletteDocumentRank, + type PaletteDocumentInput +} from './palette-document' import { segmentPaletteText } from './text-segments' import { isWithinOnePaletteEdit } from './typo-distance' @@ -383,7 +387,7 @@ describe('typo distance', () => { }) describe('container field matching', () => { - it('marks matchedDirectField as 1 and demotes quality class when all tokens land on container fields', () => { + it('counts a container-only token and demotes quality class when every token lands on containers', () => { const tabDoc: PaletteDocumentInput = { id: 'tab-1', visibleFields: [ @@ -394,11 +398,11 @@ describe('container field matching', () => { } const match = run(tabDoc, '4360') expect(match).not.toBeNull() - expect(match?.rank.matchedDirectField).toBe(1) + expect(match?.rank.containerOnlyTokenCount).toBe(1) expect(match?.qualityClass).toBe('exact-evidence') }) - it('marks matchedDirectField as 0 when at least one token lands on a direct field', () => { + it('does not count a token that lands on a direct field', () => { const tabDoc: PaletteDocumentInput = { id: 'tab-1', visibleFields: [ @@ -409,7 +413,40 @@ describe('container field matching', () => { } const match = run(tabDoc, '4360') expect(match).not.toBeNull() - expect(match?.rank.matchedDirectField).toBe(0) + expect(match?.rank.containerOnlyTokenCount).toBe(0) expect(match?.qualityClass).toBe('exact-visible') }) + + it('ranks an all-direct multi-token match ahead of a mixed direct and container match', () => { + const direct = run( + { + id: 'direct', + visibleFields: [ + { id: 'title', profile: 'structured-label', text: 'alpha' }, + { id: 'path', profile: 'structured-label', text: 'beta' } + ], + evidence: [] + }, + 'alpha beta' + ) + const mixed = run( + { + id: 'mixed', + visibleFields: [ + { id: 'title', profile: 'structured-label', text: 'alpha' }, + { id: 'worktree', profile: 'structured-label', text: 'beta', isContainer: true } + ], + evidence: [] + }, + 'alpha beta' + ) + + expect(direct).not.toBeNull() + expect(mixed).not.toBeNull() + expect(direct?.rank.containerOnlyTokenCount).toBe(0) + expect(mixed?.rank.containerOnlyTokenCount).toBe(1) + if (direct && mixed) { + expect(comparePaletteDocumentRank(direct.rank, mixed.rank)).toBeLessThan(0) + } + }) }) diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts index c36a48dc615..bf822ba3308 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { buildFocusedGroupTabRecency, + focusedGroupTabKey, orderRecentWorkspaceTabs, resolveRecentWorkspaceTabStatus, type RecentWorkspaceTabRow @@ -130,6 +131,22 @@ describe('orderRecentWorkspaceTabs', () => { ).toEqual(['warm', 'cold']) }) + it('uses host-qualified recency for same-id worktree rows', () => { + const rows = [ + row('local', { worktreeId: 'repo::/app', worktreeHostId: 'local' }), + row('ssh', { worktreeId: 'repo::/app', worktreeHostId: 'ssh:builder' }) + ] + + expect( + order(rows, sources([]), { + lastVisitedAtByWorktreeId: { + 'local|repo::/app': NOW - 1_000, + 'ssh:builder|repo::/app': NOW + } + }) + ).toEqual(['ssh', 'local']) + }) + it('prefers any visited worktree over a never-visited one', () => { const rows = [row('never'), row('ancient')] @@ -151,9 +168,9 @@ describe('orderRecentWorkspaceTabs', () => { order(rows, sources([]), { lastVisitedAtByWorktreeId: { 'wt-1': NOW }, focusedGroupTabRecency: new Map([ - ['unified-first', 0], - ['unified-third', 1], - ['unified-second', 2] + [focusedGroupTabKey('wt-1', 'unified-first'), 0], + [focusedGroupTabKey('wt-1', 'unified-third'), 1], + [focusedGroupTabKey('wt-1', 'unified-second'), 2] ]) }) ).toEqual(['second', 'third', 'first']) @@ -171,14 +188,70 @@ describe('orderRecentWorkspaceTabs', () => { expect( order(rows, sources([]), { focusedGroupTabRecency: new Map([ - ['unified-alpha-1', 0], - ['unified-alpha-2', 1], - ['unified-beta-1', 5] + [focusedGroupTabKey('wt-alpha', 'unified-alpha-1'), 0], + [focusedGroupTabKey('wt-alpha', 'unified-alpha-2'), 1], + [focusedGroupTabKey('wt-beta', 'unified-beta-1'), 5] ]) }) ).toEqual(['alpha-2', 'alpha-1', 'beta-1']) }) + it('keeps an interleaved worktree block together before applying its focused-group MRU', () => { + const rows = [ + row('alpha-old', { worktreeId: 'wt-alpha', unifiedTabId: 'unified-alpha-old' }), + row('beta', { worktreeId: 'wt-beta', unifiedTabId: 'unified-beta' }), + row('alpha-new', { worktreeId: 'wt-alpha', unifiedTabId: 'unified-alpha-new' }) + ] + + expect( + order(rows, sources([]), { + focusedGroupTabRecency: new Map([ + [focusedGroupTabKey('wt-alpha', 'unified-alpha-old'), 0], + [focusedGroupTabKey('wt-alpha', 'unified-alpha-new'), 1], + [focusedGroupTabKey('wt-beta', 'unified-beta'), 0] + ]) + }) + ).toEqual(['alpha-new', 'alpha-old', 'beta']) + }) + + it('keeps duplicate tab ids in separate worktrees on their own MRU ordinals', () => { + const rows = [ + row('alpha-1', { worktreeId: 'wt-alpha', unifiedTabId: 'shared-tab' }), + row('alpha-2', { worktreeId: 'wt-alpha', unifiedTabId: 'alpha-only' }), + row('beta', { worktreeId: 'wt-beta', unifiedTabId: 'shared-tab' }) + ] + + expect( + order(rows, sources([]), { + lastVisitedAtByWorktreeId: { 'wt-alpha': NOW, 'wt-beta': NOW - 1 }, + focusedGroupTabRecency: new Map([ + [focusedGroupTabKey('wt-alpha', 'shared-tab'), 0], + [focusedGroupTabKey('wt-alpha', 'alpha-only'), 1], + // Beta's ordinal for the same tab id must not hoist alpha's occurrence. + [focusedGroupTabKey('wt-beta', 'shared-tab'), 9] + ]) + }) + ).toEqual(['alpha-2', 'alpha-1', 'beta']) + }) + + it('keeps same-id worktrees on two hosts in separate order blocks', () => { + const rows = [ + row('local-old', { worktreeId: 'wt-1', worktreeHostId: 'local', unifiedTabId: 'local-old' }), + row('ssh', { worktreeId: 'wt-1', worktreeHostId: 'ssh:box', unifiedTabId: 'ssh' }), + row('local-new', { worktreeId: 'wt-1', worktreeHostId: 'local', unifiedTabId: 'local-new' }) + ] + + expect( + order(rows, sources([]), { + focusedGroupTabRecency: new Map([ + [focusedGroupTabKey('wt-1', 'local-old'), 0], + [focusedGroupTabKey('wt-1', 'local-new'), 1], + [focusedGroupTabKey('wt-1', 'ssh'), 5] + ]) + }) + ).toEqual(['local-new', 'local-old', 'ssh']) + }) + it('keeps input (positional) order when nothing else separates two rows', () => { const rows = [row('a', { worktreeId: 'wt-1' }), row('b', { worktreeId: 'wt-1' })] @@ -188,6 +261,21 @@ describe('orderRecentWorkspaceTabs', () => { ]) }) + it('returns each occurrence identity when palette ids collide', () => { + const rows = [ + row('workspace-tab:duplicate', { + occurrenceId: 'recent-tab:alpha', + worktreeId: 'wt-alpha' + }), + row('workspace-tab:duplicate', { + occurrenceId: 'recent-tab:beta', + worktreeId: 'wt-beta' + }) + ] + + expect(order(rows, sources([]))).toEqual(['recent-tab:alpha', 'recent-tab:beta']) + }) + it('treats rows without a terminal tab as idle', () => { const rows = [row('browser', { terminalTab: null, unifiedTabId: null }), row('blocked')] @@ -272,8 +360,8 @@ describe('buildFocusedGroupTabRecency', () => { ) expect([...recency]).toEqual([ - ['t1', 0], - ['t2', 1] + [focusedGroupTabKey('wt-1', 't1'), 0], + [focusedGroupTabKey('wt-1', 't2'), 1] ]) }) diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.ts b/src/renderer/src/lib/recent-workspace-tab-rows.ts index 5005030b81a..d17e42eed99 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.ts @@ -10,6 +10,9 @@ import { tabHasLivePty } from './tab-has-live-pty' import type { WorktreeStatus } from './worktree-status' import type { TabGroup } from '../../../shared/tab-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { getWorktreeVisitTimestamp } from './worktree-visit-recency' +import { composeWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' /** * Row model for Cmd+J's empty-query "Recent chats & terminals" section. @@ -20,7 +23,15 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' export type RecentWorkspaceTabRow = { /** Palette item id. */ id: string + /** + * Per-occurrence identity used while the palette is open. Persisted tab ids can collide across + * hosts (or in a transient duplicate snapshot), so ordering must not use `id` as its key. + * Callers that know their ids are unique may omit this and fall back to `id`. + */ + occurrenceId?: string worktreeId: string + /** Host owner used to distinguish same-id rows published by two hosts. */ + worktreeHostId?: ExecutionHostId /** Unified tab id — the key `TabGroup.recentTabIds` uses. Null for rows outside a tab group. */ unifiedTabId: string | null /** Terminal tab whose panes carry agent state. Null for editor, browser and simulator rows. */ @@ -33,18 +44,19 @@ export type RecentWorkspaceTabOrderInputs = { paneSources: TabPaneInputSources now: number lastVisitedAtByWorktreeId: Record - /** Unified tab id → ordinal in its worktree's focused group; higher is more recent. */ + /** `focusedGroupTabKey` → ordinal in that worktree's focused group; higher is more recent. */ focusedGroupTabRecency: ReadonlyMap } type RankedRow = { - id: string + occurrenceId: string needsAttention: boolean attentionClass: SmartClass attentionTimestamp: number visitedAt: number | undefined focusOrdinal: number worktreeId: string + worktreeOrder: number } /** Classes 1 (blocked/waiting) and 2 (freshly done) are the rows that want the user. */ @@ -89,6 +101,15 @@ export function resolveRecentWorkspaceTabStatus( : 'inactive' } +/** + * Ordinals are per-worktree, so the key must be too: two worktrees can publish the same tab id and + * a bare key would let one overwrite the other's MRU position. + */ +export function focusedGroupTabKey(worktreeId: string, unifiedTabId: string): string { + // NUL separator: a worktree id embeds a filesystem path, so a printable one would be ambiguous. + return `${worktreeId}\u0000${unifiedTabId}` +} + /** `TabGroup.recentTabIds` keeps most-recent at the tail, so the index is the ordinal. */ export function buildFocusedGroupTabRecency( activeGroupIdByWorktree: Record, @@ -102,7 +123,9 @@ export function buildFocusedGroupTabRecency( } // Why: MRU only means something inside the focused group; other groups keep positional order. const focusedGroup = groups?.find((group) => group.id === activeGroupId) - focusedGroup?.recentTabIds?.forEach((tabId, index) => recency.set(tabId, index)) + focusedGroup?.recentTabIds?.forEach((tabId, index) => + recency.set(focusedGroupTabKey(worktreeId, tabId), index) + ) } return recency } @@ -127,10 +150,8 @@ function compareRankedRows(a: RankedRow, b: RankedRow): number { } return b.visitedAt - a.visitedAt } - // Why: focusOrdinal is a per-worktree sequence, so comparing it across worktrees interleaves - // them arbitrarily. Keep input (positional) order instead. - if (a.worktreeId !== b.worktreeId) { - return 0 + if (a.worktreeOrder !== b.worktreeOrder) { + return a.worktreeOrder - b.worktreeOrder } return b.focusOrdinal - a.focusOrdinal } @@ -139,26 +160,41 @@ function compareRankedRows(a: RankedRow, b: RankedRow): number { * Rank rows into ids, most-wanted first: * tier 1 — needs attention: class 1 (blocked/waiting) then 2 (fresh done), newest first * tier 2 — everything else: worktree focus recency, then focused-group MRU - * Full ties keep input order, which callers pass in positional (worktree/group/tab) order. + * Equal worktree tiers preserve first-seen worktree order, then use that worktree's MRU. */ export function orderRecentWorkspaceTabs(inputs: RecentWorkspaceTabOrderInputs): string[] { const { rows, paneSources, now, lastVisitedAtByWorktreeId, focusedGroupTabRecency } = inputs + // Host-qualified: the same worktree id on two hosts is two workspaces and must not share a block. + const worktreeOrder = new Map() + for (const row of rows) { + const identity = composeWorktreeHostIdentity(row.worktreeHostId, row.worktreeId) + if (!worktreeOrder.has(identity)) { + worktreeOrder.set(identity, worktreeOrder.size) + } + } return rows .map((row): RankedRow => { const attention = resolveRecentWorkspaceTabAttention(row, paneSources, now) return { - id: row.id, + occurrenceId: row.occurrenceId ?? row.id, needsAttention: attention.cls <= NEEDS_ATTENTION_MAX_CLASS, attentionClass: attention.cls, attentionTimestamp: attention.attentionTimestamp, - visitedAt: lastVisitedAtByWorktreeId[row.worktreeId], + visitedAt: getWorktreeVisitTimestamp(lastVisitedAtByWorktreeId, { + id: row.worktreeId, + hostId: row.worktreeHostId + }), worktreeId: row.worktreeId, + worktreeOrder: + worktreeOrder.get(composeWorktreeHostIdentity(row.worktreeHostId, row.worktreeId)) ?? + Number.MAX_SAFE_INTEGER, focusOrdinal: row.unifiedTabId === null ? NO_FOCUS_ORDINAL - : (focusedGroupTabRecency.get(row.unifiedTabId) ?? NO_FOCUS_ORDINAL) + : (focusedGroupTabRecency.get(focusedGroupTabKey(row.worktreeId, row.unifiedTabId)) ?? + NO_FOCUS_ORDINAL) } }) .sort(compareRankedRows) - .map((row) => row.id) + .map((row) => row.occurrenceId) } diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index b84b50909f7..9d8cdb34857 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -11,6 +11,10 @@ import { } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' import { mergeWorkspaceSessionsFromHosts, splitWorkspaceSessionByHost, @@ -76,6 +80,9 @@ function isPlainRecord(value: unknown): value is Record { } function normalizeWorkspaceSessionKeyForOwnerMap(value: string): string { + if (isWorktreeHostIdentity(value)) { + return getWorktreeIdFromHostIdentity(value) + } const scope = parseWorkspaceKey(value) return scope?.type === 'worktree' ? scope.worktreeId : value } diff --git a/src/renderer/src/lib/workspace-session-host-split.test.ts b/src/renderer/src/lib/workspace-session-host-split.test.ts index a3aff69a888..de359cfb12e 100644 --- a/src/renderer/src/lib/workspace-session-host-split.test.ts +++ b/src/renderer/src/lib/workspace-session-host-split.test.ts @@ -113,6 +113,32 @@ describe('splitWorkspaceSessionByHost', () => { expect(Object.keys(slices[RUNTIME_B]?.tabsByWorktree ?? {})).toEqual(['b-wt']) }) + it('keeps ssh-qualified visit recency in the local slice and routes runtime-qualified keys to their partition', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { + 'local-wt': 1, + 'a-wt': 2, + 'ssh:builder|ssh-wt': 3, + 'runtime:env-a|a-wt': 4 + } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + // Why local for ssh: boot hydration reads only local + runtime:* partitions, + // so an ssh partition would strand the recency across restarts. + expect(slices[LOCAL_EXECUTION_HOST_ID]?.lastVisitedAtByWorktreeId).toEqual({ + 'local-wt': 1, + 'ssh:builder|ssh-wt': 3 + }) + expect(slices[RUNTIME_A]?.lastVisitedAtByWorktreeId).toEqual({ + 'a-wt': 2, + 'runtime:env-a|a-wt': 4 + }) + expect(slices['ssh:builder' as ExecutionHostId]).toBeUndefined() + }) + it('routes tab-keyed maps via the owning tab worktree (legacy + unified)', () => { const state: WorkspaceSessionState = { ...getDefaultWorkspaceSession(), diff --git a/src/renderer/src/lib/workspace-session-host-split.ts b/src/renderer/src/lib/workspace-session-host-split.ts index 103a1abcf63..ab374474b73 100644 --- a/src/renderer/src/lib/workspace-session-host-split.ts +++ b/src/renderer/src/lib/workspace-session-host-split.ts @@ -1,5 +1,10 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import { GLOBAL_WORKSPACE_SESSION_FIELDS, WORKSPACE_SESSION_FIELD_OWNERSHIP @@ -84,6 +89,33 @@ function assignWorktreeKeyed( } } +function assignVisitRecencyByHost( + slices: HostSessionSlices, + template: WorkspaceSessionState, + value: unknown, + ctx: SplitContext +): void { + if (!isWorkspaceSessionRecord(value)) { + return + } + for (const [key, entry] of Object.entries(value)) { + // Why: boot hydration reads only local + runtime:* partitions, and SSH worktree + // session state deliberately stays in the local partition (see buildHostIdByWorktreeId); + // routing ssh-qualified keys to an ssh partition would strand them across restarts. + const qualifiedHost = isWorktreeHostIdentity(key) + ? parseExecutionHostId(key.slice(0, key.indexOf('|'))) + : null + const host = isWorktreeHostIdentity(key) + ? qualifiedHost?.kind === 'runtime' + ? qualifiedHost.id + : LOCAL_EXECUTION_HOST_ID + : ctx.hostIdByWorktreeId(key) + const slice = ensureSlice(slices, host, template) as WorkspaceSessionRecord + const target = (slice.lastVisitedAtByWorktreeId ??= {}) as WorkspaceSessionRecord + target[key] = entry + } +} + function assignKeyedByResolvedWorktree( slices: HostSessionSlices, template: WorkspaceSessionState, @@ -157,7 +189,11 @@ export function splitWorkspaceSessionByHost( // Main preserves this field while rebasing renderer writes onto host authority. break case 'worktreeKeyed': - assignWorktreeKeyed(slices, template, field, value, ctx) + if (field === 'lastVisitedAtByWorktreeId') { + assignVisitRecencyByHost(slices, template, value, ctx) + } else { + assignWorktreeKeyed(slices, template, field, value, ctx) + } break case 'worktreeArray': { if (!Array.isArray(value)) { diff --git a/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts b/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts index 3a67ed6a11b..ae8b0d497c2 100644 --- a/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts +++ b/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts @@ -21,7 +21,7 @@ import type { AgentMetadata } from './workspace-tab-agent-metadata' */ const AGENT_SNIPPET_RANK: PaletteDocumentRank = { exactIntent: 1, - matchedDirectField: 0, + containerOnlyTokenCount: Number.MAX_SAFE_INTEGER, wholeQuery: 3, worstQuality: paletteMatchQualityRank(PALETTE_MATCH_QUALITIES.at(-1) as PaletteMatchQuality) + 1, usesSupportingEvidence: 1, diff --git a/src/renderer/src/lib/workspace-tab-palette-results.test.ts b/src/renderer/src/lib/workspace-tab-palette-results.test.ts index a62c6870ae8..8c34265d3a1 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.test.ts @@ -99,13 +99,14 @@ function makeEntry({ describe('searchWorkspaceTabs lastActiveAt', () => { it('is null when neither agent activity nor worktree activity is known', () => { - const [result] = searchWorkspaceTabs([makeEntry()], '') + const [result] = searchWorkspaceTabs([makeEntry({ createdAt: 4000 })], '') expect(result.lastActiveAt).toBeNull() }) it('falls back to worktree PTY activity for editor tabs with no agent metadata', () => { const entry = makeEntry({ contentType: 'editor', + createdAt: 1000, worktree: makeWorktree({ lastActivityAt: 5000 }) }) const [result] = searchWorkspaceTabs([entry], '') @@ -148,9 +149,61 @@ describe('searchWorkspaceTabs lastActiveAt', () => { const [result] = searchWorkspaceTabs([entry], '') expect(result.lastActiveAt).toBe(6000) }) + + it('uses a newer tab focus instead of stale agent activity', () => { + const entry = makeEntry({ + id: 'tab-refocused', + createdAt: 1000, + agentLastActivityAt: 3000 + }) + entry.tab.lastFocusedAt = 8000 + + const [result] = searchWorkspaceTabs([entry], '') + + expect(result.lastActiveAt).toBe(8000) + }) + + it('uses newer agent activity instead of an older tab focus', () => { + const entry = makeEntry({ + id: 'tab-agent-active', + createdAt: 1000, + agentLastActivityAt: 8000 + }) + entry.tab.lastFocusedAt = 3000 + + const [result] = searchWorkspaceTabs([entry], '') + + expect(result.lastActiveAt).toBe(8000) + }) }) describe('searchWorkspaceTabs ranking', () => { + it('ranks a multi-token direct-plus-container hit above a container-only whole-query hit', () => { + const directEntry = makeEntry({ id: 'direct-tab' }) + const containerEntry = makeEntry({ id: 'container-tab' }) + directEntry.document = buildPaletteTabDocument({ + id: 'direct', + title: 'Auth notes', + secondaryTexts: [], + worktreeName: 'Aurora migration', + branch: 'main', + repoName: 'repo' + }) + containerEntry.document = buildPaletteTabDocument({ + id: 'container', + title: 'Unrelated notes', + secondaryTexts: [], + worktreeName: 'Auth Aurora', + branch: 'main', + repoName: 'repo' + }) + + const results = searchWorkspaceTabs([containerEntry, directEntry], 'auth aurora') + + expect(results.map((result) => result.tabId)).toEqual(['direct-tab', 'container-tab']) + expect(results.map((result) => result.rank?.containerOnlyTokenCount)).toEqual([1, 2]) + }) + it('ranks direct tab title matches ahead of container-only worktree matches', () => { const directEntry = makeEntry({ id: 'README-4360' }) const containerEntry = makeEntry({ id: 'unrelated-file' }) @@ -175,9 +228,9 @@ describe('searchWorkspaceTabs ranking', () => { const results = searchWorkspaceTabs([containerEntry, directEntry], '4360') expect(results).toHaveLength(2) expect(results[0].tabId).toBe('README-4360') - expect(results[0].rank?.matchedDirectField).toBe(0) + expect(results[0].rank?.containerOnlyTokenCount).toBe(0) expect(results[1].tabId).toBe('unrelated-file') - expect(results[1].rank?.matchedDirectField).toBe(1) + expect(results[1].rank?.containerOnlyTokenCount).toBe(1) }) it('breaks tie between two container-matching tabs using lastActiveAt recency', () => { diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts index 67d77bec921..9eeeff525f6 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.ts @@ -87,15 +87,13 @@ function positionScore(entry: SearchableWorkspaceTab): number { } function resolveWorkspaceTabLastActiveAt(entry: SearchableWorkspaceTab): number | null { - // Agent activity wins when present; then explicit tab focus; otherwise fall back to worktree. - const candidate = - maxAgentActivityAt(entry.agentMetadata) ?? - entry.tab.lastFocusedAt ?? - (entry.worktree.lastActivityAt || null) + // Why: explicit tab activity outranks the worktree fallback; creation only clamps stale signals. + const tabLocalActivity = + Math.max(maxAgentActivityAt(entry.agentMetadata) ?? 0, entry.tab.lastFocusedAt ?? 0) || null + const candidate = tabLocalActivity || entry.worktree.lastActivityAt || null if (candidate == null) { return null } - // Never report activity older than the tab itself (e.g. a stale worktree signal). return Math.max(candidate, entry.tab.createdAt) } diff --git a/src/renderer/src/lib/worktree-palette-task-url-result.ts b/src/renderer/src/lib/worktree-palette-task-url-result.ts index 303083dff92..27f06757387 100644 --- a/src/renderer/src/lib/worktree-palette-task-url-result.ts +++ b/src/renderer/src/lib/worktree-palette-task-url-result.ts @@ -38,7 +38,7 @@ export function buildWorktreePaletteTaskUrlResult(args: { qualityClass: 'exact-intent', rank: { exactIntent: 0, - matchedDirectField: 0, + containerOnlyTokenCount: 0, wholeQuery: 0, worstQuality: 0, usesSupportingEvidence: 1, diff --git a/src/renderer/src/lib/worktree-visit-recency.test.ts b/src/renderer/src/lib/worktree-visit-recency.test.ts new file mode 100644 index 00000000000..4b431886a1a --- /dev/null +++ b/src/renderer/src/lib/worktree-visit-recency.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + getWorktreeVisitKey, + getWorktreeVisitTimestamp, + removeWorktreeVisitEntries +} from './worktree-visit-recency' + +describe('worktree visit recency identities', () => { + it('keeps same-id host twins independent when reading and writing keys', () => { + const id = 'repo::/srv/app' + const localKey = getWorktreeVisitKey(id, 'local') + const sshKey = getWorktreeVisitKey(id, 'ssh:builder') + const timestamps = { [localKey]: 100, [sshKey]: 200 } + + expect(localKey).toBe(`local|${id}`) + expect(sshKey).toBe(`ssh:builder|${id}`) + expect(getWorktreeVisitTimestamp(timestamps, { id, hostId: 'local' })).toBe(100) + expect(getWorktreeVisitTimestamp(timestamps, { id, hostId: 'ssh:builder' })).toBe(200) + }) + + it('falls back to a legacy bare key only when the host key is absent', () => { + const id = 'repo::/srv/app' + expect(getWorktreeVisitTimestamp({ [id]: 100 }, { id, hostId: 'ssh:builder' })).toBe(100) + expect( + getWorktreeVisitTimestamp( + { [id]: 100, [getWorktreeVisitKey(id, 'ssh:builder')]: 200 }, + { id, hostId: 'ssh:builder' } + ) + ).toBe(200) + }) + + it('removes one host key without deleting the other host twin or legacy fallback', () => { + const id = 'repo::/srv/app' + const timestamps = { + [id]: 50, + [getWorktreeVisitKey(id, 'local')]: 100, + [getWorktreeVisitKey(id, 'ssh:builder')]: 200 + } + expect(removeWorktreeVisitEntries(timestamps, new Set([id]), 'local')).toEqual({ + [id]: 50, + [getWorktreeVisitKey(id, 'ssh:builder')]: 200 + }) + }) +}) diff --git a/src/renderer/src/lib/worktree-visit-recency.ts b/src/renderer/src/lib/worktree-visit-recency.ts new file mode 100644 index 00000000000..0fa9533ce33 --- /dev/null +++ b/src/renderer/src/lib/worktree-visit-recency.ts @@ -0,0 +1,103 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import { + composeWorktreeHostIdentity, + getWorktreeHostIdentity, + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' +import type { Worktree } from '../../../shared/worktree/types' + +/** The persisted focus-recency map is host-qualified for new writes. */ +export type WorktreeVisitOwner = Pick + +/** + * Read recency for one visible row. + * + * The bare lookup is deliberately second: it keeps sessions written before + * host-qualified recency useful without letting a legacy value override a + * newer host-specific visit. + */ +export function getWorktreeVisitTimestamp( + timestamps: Readonly> | undefined, + worktree: WorktreeVisitOwner +): number | undefined { + if (!timestamps) { + return undefined + } + const qualified = timestamps[getWorktreeHostIdentity(worktree)] + return qualified ?? timestamps[worktree.id] +} + +/** + * Key used for a newly stamped visit. Unknown-host rows keep the legacy bare + * key because there is no safe host qualification to persist; once a row has + * host evidence, all new writes use the canonical `${host}|${id}` form. + */ +export function getWorktreeVisitKey(worktreeId: string, hostId?: ExecutionHostId): string { + return hostId ? composeWorktreeHostIdentity(hostId, worktreeId) : worktreeId +} + +/** + * Decode only keys that use the canonical host-qualified shape. Bare legacy + * ids are returned unchanged, including ids that happen to contain `|`. + */ +export function getWorktreeIdFromVisitKey(key: string): string { + const separator = key.indexOf('|') + if (separator === -1) { + return key + } + // Empty host is the canonical unknown-host bucket. Known host ids are the + // other valid prefixes; a legacy worktree id may itself contain `|`. + if (isWorktreeHostIdentity(key)) { + return getWorktreeIdFromHostIdentity(key) + } + return key +} + +export function isHostQualifiedVisitKey(key: string): boolean { + return getWorktreeIdFromVisitKey(key) !== key +} + +/** Remove all recency entries belonging to one or more raw worktree ids. */ +export function removeWorktreeVisitEntries( + timestamps: Readonly>, + worktreeIds: ReadonlySet, + hostId?: ExecutionHostId +): Record { + return removeWorktreeVisitEntriesForTargets( + timestamps, + [...worktreeIds].map((id) => ({ id, hostId })) + ) +} + +/** + * Remove recency for bulk purge targets. A host-qualified target only removes + * that host's key; an unknown-host target deliberately retains the legacy + * raw-id behavior because no owner can safely be inferred. + */ +export function removeWorktreeVisitEntriesForTargets( + timestamps: Readonly>, + targets: readonly WorktreeVisitOwner[] +): Record { + let changed = false + const next: Record = {} + const qualified = new Set() + const legacyIds = new Set() + for (const target of targets) { + if (target.hostId) { + qualified.add(getWorktreeVisitKey(target.id, target.hostId)) + } else { + legacyIds.add(target.id) + } + } + for (const [key, timestamp] of Object.entries(timestamps)) { + const rawId = getWorktreeIdFromVisitKey(key) + const remove = qualified.has(key) || legacyIds.has(rawId) + if (remove) { + changed = true + continue + } + next[key] = timestamp + } + return changed ? next : (timestamps as Record) +} diff --git a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts index d70f253b94a..71cde0bac72 100644 --- a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts +++ b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts @@ -257,6 +257,11 @@ describe('purgeStaleRuntimeHostState', () => { ] }, tabsByWorktree: { [worktreeId]: tabs }, + lastVisitedAtByWorktreeId: { + [worktreeId]: 50, + [`${RUNTIME_A}|${worktreeId}`]: 100, + [`${RUNTIME_B}|${worktreeId}`]: 200 + }, restoredRuntimeHostIdByWorkspaceSessionKey: { [worktreeId]: RUNTIME_B }, activeWorktreeId: worktreeId, activeWorkspaceKey: worktreeWorkspaceKey(worktreeId) @@ -269,6 +274,10 @@ describe('purgeStaleRuntimeHostState', () => { expect(s.worktreesByRepo.shared).toHaveLength(1) expect(s.worktreesByRepo.shared[0]?.hostId).toBe(RUNTIME_B) expect(s.tabsByWorktree[worktreeId]).toBe(tabs) + expect(s.lastVisitedAtByWorktreeId).toEqual({ + [worktreeId]: 50, + [`${RUNTIME_B}|${worktreeId}`]: 200 + }) expect(s.restoredRuntimeHostIdByWorkspaceSessionKey).toBe(restoredOwners) expect(s.restoredRuntimeHostIdByWorkspaceSessionKey[worktreeId]).toBe(RUNTIME_B) expect(s.activeWorktreeId).toBe(worktreeId) diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts index 5eba51545d4..1c8cd78be57 100644 --- a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -366,6 +366,31 @@ describe('repo slice host identity routing', () => { expect(ptyKill).not.toHaveBeenCalledWith('remote-pty') }) + it('preserves qualified recency for an exact-id sibling host', async () => { + const worktreeId = 'same-repo::/shared/wt' + const localWorktree = makeWorktree({ id: worktreeId, repoId: 'same-repo' }) + const remoteWorktree = makeWorktree({ + id: worktreeId, + repoId: 'same-repo', + hostId: 'runtime:env-1' + }) + const store = createTestStore() + store.setState({ + repos: [localDuplicate, remoteDuplicate], + worktreesByRepo: { 'same-repo': [localWorktree, remoteWorktree] }, + lastVisitedAtByWorktreeId: { + [`local|${worktreeId}`]: 10, + [`runtime:env-1|${worktreeId}`]: 20 + } + }) + + await store.getState().removeProject('same-repo', { hostId: 'local' }) + + expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ + [`runtime:env-1|${worktreeId}`]: 20 + }) + }) + it('removeProject with an explicit hostId routes to that host, not the focused one', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-explicit-host', diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 91cb7655790..56bd44159e6 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -50,6 +50,7 @@ import { applyManualRepoOrder, getManualRepoOrder } from '../../../../shared/man import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { getWorktreeIdFromVisitKey, getWorktreeVisitKey } from '@/lib/worktree-visit-recency' import { structuralValuesEqual } from '../../../../shared/structural-value-equality' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' import { @@ -3700,6 +3701,20 @@ export const createRepoSlice: StateCreator = (set, // Kill PTYs for all worktrees belonging to this repo const worktreeIds = getKnownRepoWorktreeIds(get(), projectId, ownerHostId) + // A raw id can be published by two hosts. Keep the purge host-scoped for + // those twins so the sibling's qualified visit recency survives. + const knownRepoWorktrees = [ + ...(get().worktreesByRepo[projectId] ?? []), + ...(get().detectedWorktreesByRepo[projectId]?.worktrees ?? []) + ] + const exactSiblingIds = new Set( + knownRepoWorktrees + .filter((worktree) => !worktreeBelongsToHost(worktree, ownerHostId)) + .map((worktree) => worktree.id) + ) + const purgeTargets = worktreeIds.map((id) => + exactSiblingIds.has(id) ? { id, hostId: ownerHostId } : id + ) const localAgentContextProjectIds = ownerHostId === LOCAL_EXECUTION_HOST_ID ? [ @@ -3735,7 +3750,7 @@ export const createRepoSlice: StateCreator = (set, } // Why: use the canonical per-worktree purge to evict all worktree-scoped maps (hand-deletion leaked most); runs before the set() below so it still sees tabsByWorktree. - get().purgeWorktreeTerminalState(worktreeIds) + get().purgeWorktreeTerminalState(purgeTargets) get().clearLocalDetectedAgentContextsForProjects(localAgentContextProjectIds) set((s) => { @@ -3774,6 +3789,9 @@ export const createRepoSlice: StateCreator = (set, } // Why: editor state is worktree-scoped; clear the repo's open files + active-file tracking so orphans don't linger in the session save. const worktreeIdSet = new Set(worktreeIds) + const removedVisitKeys = new Set( + worktreeIds.map((worktreeId) => getWorktreeVisitKey(worktreeId, ownerHostId)) + ) const nextOpenFiles = s.openFiles.filter((f) => !worktreeIdSet.has(f.worktreeId)) const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree } const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } @@ -3789,9 +3807,11 @@ export const createRepoSlice: StateCreator = (set, const repoIdFullyRemoved = !nextRepos.some((r) => r.id === projectId) let nextLastVisitedAtByWorktreeId = s.lastVisitedAtByWorktreeId for (const id of Object.keys(s.lastVisitedAtByWorktreeId)) { + const rawId = getWorktreeIdFromVisitKey(id) if ( - worktreeIdSet.has(id) || - (repoIdFullyRemoved && getRepoIdFromWorktreeId(id) === projectId) + (ownerHostId && removedVisitKeys.has(id)) || + (!ownerHostId && worktreeIdSet.has(rawId)) || + (repoIdFullyRemoved && getRepoIdFromWorktreeId(rawId) === projectId) ) { if (nextLastVisitedAtByWorktreeId === s.lastVisitedAtByWorktreeId) { nextLastVisitedAtByWorktreeId = { ...s.lastVisitedAtByWorktreeId } diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index b93fa13571e..063bd9d1d4f 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -43,6 +43,7 @@ import { throwIfWorkspaceCleanupScanSuperseded } from './workspace-cleanup-broad-scan-registry' import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' +import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency' import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' export type WorkspaceCleanupFailure = { @@ -821,7 +822,12 @@ function getInitialWorkspaceCleanupGitDeferrals(state: AppState): string[] { const hasVisibleContext = openEditorWorktreeIds.has(worktreeId) || (state.browserTabsByWorktree[worktreeId]?.length ?? 0) > 0 - const lastVisitedAt = state.lastVisitedAtByWorktreeId[worktreeId] ?? 0 + // Why: enrichment state may be a plain snapshot without slice methods. + const lastVisitedAt = + getWorktreeVisitTimestamp(state.lastVisitedAtByWorktreeId, { + id: worktreeId, + hostId: state.getKnownWorktreeById?.(worktreeId)?.hostId + }) ?? 0 if ( hasVisibleContext && lastVisitedAt > 0 && @@ -991,7 +997,11 @@ function getWorkspaceCleanupLocalStateSignature( browserTabCount: (state.browserTabsByWorktree[worktreeId] ?? []).length, retainedDoneAgentPaneKeys, agentStatuses, - lastVisitedAt: state.lastVisitedAtByWorktreeId[worktreeId] ?? 0, + lastVisitedAt: + getWorktreeVisitTimestamp(state.lastVisitedAtByWorktreeId, { + id: worktreeId, + hostId: getWorkspaceCleanupCandidateHostId(candidate) + }) ?? 0, viewed: state.workspaceCleanupViewedCandidates[worktreeId] ?? null, dismissal }) @@ -1036,7 +1046,11 @@ async function enrichWorkspaceCleanupCandidate( blockers.push('terminal-liveness-unknown') } - const lastVisitedAt = state.lastVisitedAtByWorktreeId[candidate.worktreeId] ?? 0 + const lastVisitedAt = + getWorktreeVisitTimestamp(state.lastVisitedAtByWorktreeId, { + id: candidate.worktreeId, + hostId: getWorkspaceCleanupCandidateHostId(candidate) + }) ?? 0 const hasVisibleContext = cleanEditorTabCount > 0 || browserTabCount > 0 if ( hasVisibleContext && diff --git a/src/renderer/src/store/slices/worktree-delete-state-types.ts b/src/renderer/src/store/slices/worktree-delete-state-types.ts new file mode 100644 index 00000000000..85178bc15dc --- /dev/null +++ b/src/renderer/src/store/slices/worktree-delete-state-types.ts @@ -0,0 +1,15 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { WorktreeForceDeleteReason } from '../../../../shared/worktree/removal' +import type { Worktree } from '../../../../shared/worktree/types' + +export type WorktreeDeleteState = { + isDeleting: boolean + phase?: 'deleting' | 'queued' + executionHostId?: ExecutionHostId | null + error: string | null + canForceDelete: boolean + forceDeleteReason: WorktreeForceDeleteReason | null + lockReason?: string | null +} + +export type WorktreeDeleteStateTarget = Pick diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index a67bcfdfd6b..fb368bec070 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -24,10 +24,7 @@ import type { Worktree } from '../../../../shared/worktree/types' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import type { - WorktreeForceDeleteReason, - WorktreeRemovalTarget -} from '../../../../shared/worktree/removal' +import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal' import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { RemoveWorktreeOptions } from './worktree-removal-options' @@ -43,20 +40,12 @@ import type { import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import type { AppState } from '../types' import type { WorktreeRefreshAllOptions } from './worktree-refresh-options' +export type { WorktreePurgeTarget, WorktreePurgeTargets } from './worktree-purge-target' +import type { WorktreePurgeTargets } from './worktree-purge-target' +export type { WorktreeDeleteState, WorktreeDeleteStateTarget } from './worktree-delete-state-types' +import type { WorktreeDeleteState, WorktreeDeleteStateTarget } from './worktree-delete-state-types' export { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' -export type WorktreeDeleteState = { - isDeleting: boolean - phase?: 'deleting' | 'queued' - executionHostId?: ExecutionHostId | null - error: string | null - canForceDelete: boolean - forceDeleteReason: WorktreeForceDeleteReason | null - lockReason?: string | null -} - -export type WorktreeDeleteStateTarget = Pick - import type { RendererRemoveWorktreeResult } from './renderer-remove-worktree-result' export type WorktreeFetchOptions = { @@ -147,6 +136,7 @@ export type WorktreeSlice = { * (activateAndRevealWorktree), NOT from background activity events or raw * `setActiveWorktree` calls. See docs/cmd-j-empty-query-ordering.md. */ + /** New host-qualified rows use `${host}|${worktreeId}`; legacy bare ids remain readable. */ lastVisitedAtByWorktreeId: Record /** * Guards the one-shot hydration-time purge in `fetchAllWorktrees`. Set to @@ -296,7 +286,11 @@ export type WorktreeSlice = { * stored value. Called from user-initiated activations only. See * docs/cmd-j-empty-query-ordering.md. */ - markWorktreeVisited: (worktreeId: string, visitedAt?: number) => void + markWorktreeVisited: ( + worktreeId: string, + visitedAt?: number, + executionHostId?: ExecutionHostId + ) => void /** * Drop `lastVisitedAtByWorktreeId` entries whose worktree IDs no longer * exist. Must be called AFTER worktree hydration completes — repos load @@ -337,7 +331,7 @@ export type WorktreeSlice = { * Called by the `worktrees:changed` listener on server-side deletions and * one-shot at hydration time. See design §4.4. */ - purgeWorktreeTerminalState: (worktreeIds: string[]) => void + purgeWorktreeTerminalState: (worktreeTargets: WorktreePurgeTargets) => void /** * Retires every client-store row (repos, project host setups, worktree + * detected-worktree rows, and their tab/PTY/browser/editor cascade) owned by a diff --git a/src/renderer/src/store/slices/worktree-purge-target.ts b/src/renderer/src/store/slices/worktree-purge-target.ts new file mode 100644 index 00000000000..031765acccf --- /dev/null +++ b/src/renderer/src/store/slices/worktree-purge-target.ts @@ -0,0 +1,7 @@ +import type { Worktree } from '../../../../shared/worktree/types' + +/** A purge target with optional host ownership for host-scoped state. */ +export type WorktreePurgeTarget = Pick + +/** Strings remain supported for callers that only have a raw worktree id. */ +export type WorktreePurgeTargets = readonly (string | WorktreePurgeTarget)[] diff --git a/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts b/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts index c2519d6909a..b845b76e594 100644 --- a/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts +++ b/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts @@ -115,6 +115,18 @@ describe('setActiveWorktree focus handling', () => { }) describe('markWorktreeVisited', () => { + it('stamps a host-qualified key when the worktree owner is known', () => { + const store = createTestStore() + const worktree = makeWorktree({ id: 'repo1::/hosted', repoId: 'repo1', hostId: 'ssh:builder' }) + store.setState({ worktreesByRepo: { repo1: [worktree] } } as Partial) + + store.getState().markWorktreeVisited(worktree.id, 1000) + + expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ + [`ssh:builder|${worktree.id}`]: 1000 + }) + }) + it('is monotonic: an older timestamp does not regress the stored value', () => { const store = createTestStore() store.getState().markWorktreeVisited('wt-1', 1000) @@ -155,6 +167,28 @@ describe('markWorktreeVisited', () => { expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ 'repo1::/a': 100 }) }) + it('prunes host-qualified recency by host while retaining a same-id twin', () => { + const store = createTestStore() + const id = 'repo1::/shared' + const local = makeWorktree({ id, repoId: 'repo1', path: '/shared', hostId: 'local' }) + const ssh = makeWorktree({ id, repoId: 'repo1', path: '/shared', hostId: 'ssh:builder' }) + store.setState({ + worktreesByRepo: { repo1: [local, ssh] }, + lastVisitedAtByWorktreeId: { + [`local|${id}`]: 100, + [`ssh:builder|${id}`]: 200, + 'local|repo1::/gone': 300 + } + } as Partial) + + store.getState().pruneLastVisitedTimestamps() + + expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ + [`local|${id}`]: 100, + [`ssh:builder|${id}`]: 200 + }) + }) + it('pruneLastVisitedTimestamps preserves entries for not-yet-hydrated repos (e.g. SSH pre-connect)', () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a' }) diff --git a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts index 2311aaf0294..46b4f2fa6b4 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts @@ -162,7 +162,12 @@ export function mergeFetchedWorktrees( } } : {}), - ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) + ...(removedIds.length > 0 + ? buildWorktreePurgeState( + s, + removedIds.map((id) => ({ id, hostId: args.hostId })) + ) + : {}) } }) if (admitted) { diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts index b2b25992d7a..1a841fbdfc9 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts @@ -1,6 +1,7 @@ import type { AppState } from '../../../types' import { splitWorktreeIdForFilesystem } from '../../../../../../shared/worktree/id' import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope' +import { getWorktreeIdFromVisitKey } from '@/lib/worktree-visit-recency' import { remapClosedTerminalTabSnapshotCwds, type ClosedTerminalTabSnapshot @@ -41,7 +42,6 @@ const WORKTREE_ID_KEYED_MAP_KEYS = [ 'gitBranchCompareRequestStatusHeadByWorktree', 'showDotfilesByWorktree', 'expandedDirs', - 'lastVisitedAtByWorktreeId', 'defaultTerminalTabsAppliedByWorktreeId', 'recentlyClosedTabKindsByWorktree' ] as const satisfies readonly (keyof AppState)[] @@ -98,6 +98,24 @@ export function buildWorktreeRenameState( for (const key of WORKTREE_ID_KEYED_MAP_KEYS) { renameKey(key, renameValueByKey[key] as ((value: unknown) => unknown) | undefined) } + // Recency keys may carry a host prefix. Preserve that prefix while moving + // the path-derived id so a rename cannot merge host twins. + const nextVisitRecency = { ...s.lastVisitedAtByWorktreeId } + let visitRecencyChanged = false + for (const [key, value] of Object.entries(s.lastVisitedAtByWorktreeId)) { + const rawId = getWorktreeIdFromVisitKey(key) + if (rawId !== oldWorktreeId) { + continue + } + const nextKey = + rawId === key ? newWorktreeId : `${key.slice(0, key.length - rawId.length)}${newWorktreeId}` + nextVisitRecency[nextKey] = value + delete nextVisitRecency[key] + visitRecencyChanged = true + } + if (visitRecencyChanged) { + renamed.lastVisitedAtByWorktreeId = nextVisitRecency + } // Re-key on rename so a renamed worktree keeps its editor-undo + push/pull state. renameKey('recentlyClosedEditorTabsByWorktree', (files: { worktreeId: string }[]) => files.map(withNewWorktreeId) diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index f63712427c5..c6fd3143b17 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -82,11 +82,14 @@ export function createPurgeWorktreeTerminalState( set: WorktreeSliceSet, _get: WorktreeSliceGet ): WorktreeSlice['purgeWorktreeTerminalState'] { - return (worktreeIds: string[]) => { - const purgeableWorktreeIds = worktreeIds.filter((id) => id !== FLOATING_TERMINAL_WORKTREE_ID) - if (purgeableWorktreeIds.length === 0) { + return (worktreeTargets) => { + const purgeableWorktreeTargets = worktreeTargets.filter((target) => { + const worktreeId = typeof target === 'string' ? target : target.id + return worktreeId !== FLOATING_TERMINAL_WORKTREE_ID + }) + if (purgeableWorktreeTargets.length === 0) { return } - set((s) => buildWorktreePurgeState(s, purgeableWorktreeIds)) + set((s) => buildWorktreePurgeState(s, purgeableWorktreeTargets)) } } diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts index 76776597d7e..5cfc5eef3d2 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts @@ -2,24 +2,39 @@ import type { WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import { getRepoIdFromWorktreeId } from '../../worktree-helpers' import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { + getWorktreeIdFromVisitKey, + getWorktreeVisitKey, + getWorktreeVisitTimestamp +} from '@/lib/worktree-visit-recency' export function createMarkWorktreeVisited( set: WorktreeSliceSet, _get: WorktreeSliceGet ): WorktreeSlice['markWorktreeVisited'] { - return (worktreeId, visitedAt) => { + return (worktreeId, visitedAt, executionHostId) => { // Why: Cmd+J empty-query ordering needs a focus-recency signal distinct from lastActivityAt (background PTY/activity). // Monotonic: CLI/IPC activations can race, so older timestamps must not regress. See docs/cmd-j-empty-query-ordering.md. set((s) => { const now = visitedAt ?? Date.now() - const prev = s.lastVisitedAtByWorktreeId[worktreeId] ?? 0 + const ownerHostId = + executionHostId ?? + (s.activeWorktreeId === worktreeId ? s.activeWorkspaceExecutionHostId : undefined) ?? + s.getKnownWorktreeById(worktreeId, executionHostId)?.hostId + const visitKey = getWorktreeVisitKey(worktreeId, ownerHostId) + const prev = + getWorktreeVisitTimestamp(s.lastVisitedAtByWorktreeId, { + id: worktreeId, + hostId: ownerHostId + }) ?? 0 if (!(now > prev)) { return {} } return { lastVisitedAtByWorktreeId: { ...s.lastVisitedAtByWorktreeId, - [worktreeId]: now + [visitKey]: now } } }) @@ -35,30 +50,41 @@ export function createPruneLastVisitedTimestamps( // Why: prune per-repo, not globally — SSH repos aren't hydrated at startup, so a global prune would wipe SSH focus-recency. // Only drop for repos with a populated/authoritative list; a missing repoId means not-yet-hydrated (defer). const validIdsByRepo = new Map>() + const validWorktreeIds = new Set() + const validVisitKeys = new Set() + const addValidWorktree = (worktree: { id: string; hostId?: ExecutionHostId }): void => { + validWorktreeIds.add(worktree.id) + validVisitKeys.add(getWorktreeVisitKey(worktree.id, worktree.hostId)) + } for (const [repoId, list] of Object.entries(s.worktreesByRepo)) { // An empty list is the not-yet-hydrated shape, not an authoritative "no worktrees". if (s.detectedWorktreesByRepo[repoId] || list.length === 0) { continue } validIdsByRepo.set(repoId, new Set(list.map((worktree) => worktree.id))) + list.forEach(addValidWorktree) } for (const [repoId, result] of Object.entries(s.detectedWorktreesByRepo)) { if (result.authoritative) { validIdsByRepo.set(repoId, new Set(result.worktrees.map((worktree) => worktree.id))) + result.worktrees.forEach(addValidWorktree) } } let changed = false const next: Record = {} - for (const [id, ts] of Object.entries(s.lastVisitedAtByWorktreeId)) { + for (const [key, ts] of Object.entries(s.lastVisitedAtByWorktreeId)) { + const id = getWorktreeIdFromVisitKey(key) const repoId = getRepoIdFromWorktreeId(id) const repoIds = validIdsByRepo.get(repoId) if (!repoIds) { // Repo not yet hydrated (e.g. SSH not connected). Keep the entry. - next[id] = ts + next[key] = ts continue } - if (repoIds.has(id)) { - next[id] = ts + // Legacy bare keys remain valid when any host still publishes the id; + // qualified keys must match the exact host-qualified row. + if (getWorktreeIdFromVisitKey(key) === key ? repoIds.has(id) : validVisitKeys.has(key)) { + next[key] = ts } else { changed = true } @@ -113,13 +139,15 @@ export function createSeedActiveWorktreeLastVisitedIfMissing( if (!id) { return {} } - if (s.lastVisitedAtByWorktreeId[id] != null) { + const hostId = s.activeWorkspaceExecutionHostId ?? s.getKnownWorktreeById(id)?.hostId + const key = getWorktreeVisitKey(id, hostId) + if (getWorktreeVisitTimestamp(s.lastVisitedAtByWorktreeId, { id, hostId }) != null) { return {} } return { lastVisitedAtByWorktreeId: { ...s.lastVisitedAtByWorktreeId, - [id]: Date.now() + [key]: Date.now() } } }) diff --git a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts index a23f0707d91..8894499b714 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts @@ -1,4 +1,4 @@ -import type { WorktreeSlice } from '../../worktree-helpers' +import type { WorktreePurgeTarget, WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import type { ProjectHostSetup } from '../../../../../../shared/project-types' import type { Repo } from '../../../../../../shared/repo-types' @@ -15,6 +15,7 @@ import { isRemovedRuntimeHostId } from '../../stale-runtime-host-rows' import { buildWorktreePurgeState } from './worktree-purge-state' +import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency' export function createPurgeStaleRuntimeHostState( set: WorktreeSliceSet, @@ -86,6 +87,8 @@ export function createPurgeStaleRuntimeHostState( recordWorktreeOwners(s.worktreesByRepo) recordWorktreeOwners(detectedRows) + const removedWorktreeTargets: WorktreePurgeTarget[] = [] + const seenRemovedWorktreeTargets = new Set() const sessionWorktreeIdsOwnedByRemovedHosts = new Set() let survivingRestoredSessionOwners = s.restoredRuntimeHostIdByWorkspaceSessionKey for (const [workspaceKey, hostId] of Object.entries( @@ -103,6 +106,12 @@ export function createPurgeStaleRuntimeHostState( continue } sessionWorktreeIdsOwnedByRemovedHosts.add(worktreeId) + // Restored session ownership is authoritative even before catalog rows hydrate. + const identity = `${hostId}\u0000${worktreeId}` + if (!seenRemovedWorktreeTargets.has(identity)) { + seenRemovedWorktreeTargets.add(identity) + removedWorktreeTargets.push({ id: worktreeId, hostId }) + } repoIdsWithRemovedOwners.add(repoId) if (survivingRestoredSessionOwners === s.restoredRuntimeHostIdByWorkspaceSessionKey) { survivingRestoredSessionOwners = { ...survivingRestoredSessionOwners } @@ -116,6 +125,55 @@ export function createPurgeStaleRuntimeHostState( repoIdsWithoutSurvivingOwners.delete(repoId) } + // Preserve host ownership for recency teardown. The other bulk maps are + // keyed by raw worktree id, but a host-qualified visit must not be + // removed when an id twin survives on another host. + const addRemovedWorktreeTarget = ( + worktreeId: string, + hostId: ExecutionHostId | undefined + ): void => { + const identity = `${hostId ?? ''}\u0000${worktreeId}` + if (seenRemovedWorktreeTargets.has(identity)) { + return + } + seenRemovedWorktreeTargets.add(identity) + removedWorktreeTargets.push(hostId ? { id: worktreeId, hostId } : { id: worktreeId }) + } + const recordRemovedRowTargets = ( + rowsByRepo: Record< + string, + readonly { + id: string + hostId?: ExecutionHostId + runtimeOwnerEnvironmentId?: string + }[] + > + ): void => { + for (const [repoId, rows] of Object.entries(rowsByRepo)) { + for (const row of rows) { + const removedOwner = + (row.runtimeOwnerEnvironmentId !== undefined && + removed.has(row.runtimeOwnerEnvironmentId)) || + isRemovedRuntimeHostId(row.hostId, removed) || + (row.hostId === undefined && repoIdsWithoutSurvivingOwners.has(repoId)) + if (!removedOwner) { + continue + } + // A row's execution host is the recency owner. Runtime ownership + // is the only host evidence available for legacy rows without it. + addRemovedWorktreeTarget( + row.id, + row.hostId ?? + (row.runtimeOwnerEnvironmentId + ? toRuntimeExecutionHostId(row.runtimeOwnerEnvironmentId) + : undefined) + ) + } + } + } + recordRemovedRowTargets(s.worktreesByRepo) + recordRemovedRowTargets(detectedRows) + const worktreeDrop = dropWorktreeRowsForRemovedRuntimeEnvironments( s.worktreesByRepo, removed, @@ -163,8 +221,24 @@ export function createPurgeStaleRuntimeHostState( } } } - const purgeState = - removedWorktreeIds.size > 0 ? buildWorktreePurgeState(s, [...removedWorktreeIds]) : {} + const purgeTargets = [...removedWorktreeIds].flatMap((worktreeId) => { + const targets = removedWorktreeTargets.filter((target) => target.id === worktreeId) + return targets.length > 0 ? targets : [{ id: worktreeId }] + }) + const purgeState = purgeTargets.length > 0 ? buildWorktreePurgeState(s, purgeTargets) : {} + const visitPurgeTargets = [ + ...removedWorktreeTargets, + ...purgeTargets.filter( + (target) => !removedWorktreeTargets.some((candidate) => candidate.id === target.id) + ) + ] + const nextLastVisitedAtByWorktreeId = removeWorktreeVisitEntriesForTargets( + s.lastVisitedAtByWorktreeId, + visitPurgeTargets + ) + if (nextLastVisitedAtByWorktreeId !== s.lastVisitedAtByWorktreeId) { + purgeState.lastVisitedAtByWorktreeId = nextLastVisitedAtByWorktreeId + } const restoredSessionOwnersChanged = survivingRestoredSessionOwners !== s.restoredRuntimeHostIdByWorkspaceSessionKey diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts index 37486927717..08613ebdc45 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts @@ -1,11 +1,14 @@ import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' import type { WorktreeSliceSet } from '../listing/worktree-slice-types' import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state' +import { removeWorktreeVisitEntries } from '@/lib/worktree-visit-recency' export function applyRemoveWorktreeSuccessState( set: WorktreeSliceSet, worktreeId: string, - tabIds: Set + tabIds: Set, + executionHostId?: ExecutionHostId ): void { set((s) => { const next = { ...s.worktreesByRepo } @@ -147,14 +150,11 @@ export function applyRemoveWorktreeSuccessState( const nextEverActivatedWorktreeIds = s.everActivatedWorktreeIds.has(worktreeId) ? new Set([...s.everActivatedWorktreeIds].filter((id) => id !== worktreeId)) : s.everActivatedWorktreeIds - const nextLastVisitedAtByWorktreeId = - worktreeId in s.lastVisitedAtByWorktreeId - ? (() => { - const next = { ...s.lastVisitedAtByWorktreeId } - delete next[worktreeId] - return next - })() - : s.lastVisitedAtByWorktreeId + const nextLastVisitedAtByWorktreeId = removeWorktreeVisitEntries( + s.lastVisitedAtByWorktreeId, + new Set([worktreeId]), + executionHostId + ) return { worktreesByRepo: next, worktreeLineageById: nextLineage, diff --git a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts index 971d8f06397..329266ae369 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts @@ -59,7 +59,7 @@ export async function tearDownRemovedWorktreeRendererState(args: { // Why: dispose parked terminal watchers only on explicit deletion; identity migration/remounts must keep buffered PTY state. disposeRemovedWorktreeParkedTerminalWatchers(worktreeId, terminalPtyIdsBeforeRemoval) - applyRemoveWorktreeSuccessState(set, worktreeId, tabIds) + applyRemoveWorktreeSuccessState(set, worktreeId, tabIds, requiredExecutionHostId ?? hostId) get().removeWorkspaceSpaceWorktrees?.( hostId ? [{ id: worktreeId, executionHostId: hostId }] : [worktreeId] ) diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 4445dd306bc..68b5b2883c3 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -1,13 +1,21 @@ import type { AppState } from '../../../types' +import type { WorktreePurgeTarget, WorktreePurgeTargets } from '../../worktree-helpers' import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals' import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' import { pruneHostedReviewLinkMutationGenerations } from '../metadata/hosted-review-link-mutation' import { collectWorktreePurgeDoomedIds } from './worktree-purge-doomed-ids' import { createWorktreePurgeOmitters } from './worktree-purge-omitters' import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state' +import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency' -export function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial { - const worktreeIdSet = new Set(worktreeIds) +export function buildWorktreePurgeState( + s: AppState, + worktreeTargets: WorktreePurgeTargets +): Partial { + const normalizedTargets: WorktreePurgeTarget[] = worktreeTargets.map((target) => + typeof target === 'string' ? { id: target } : target + ) + const worktreeIdSet = new Set(normalizedTargets.map((target) => target.id)) pruneHostedReviewLinkMutationGenerations(worktreeIdSet) // Why: every authoritative and explicit purge converges here, so a deleted path can't inherit stale UI state. forgetHugeRepoWarningDismissalsForWorktrees(worktreeIdSet) @@ -172,7 +180,10 @@ export function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Par // Top-level actives openFiles: nextOpenFiles, everActivatedWorktreeIds: nextEverActivatedWorktreeIds, - lastVisitedAtByWorktreeId: omitByWorktree(s.lastVisitedAtByWorktreeId), + lastVisitedAtByWorktreeId: removeWorktreeVisitEntriesForTargets( + s.lastVisitedAtByWorktreeId, + normalizedTargets + ), // Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (write-once default-terminal guard). defaultTerminalTabsAppliedByWorktreeId: omitByWorktree( s.defaultTerminalTabsAppliedByWorktreeId diff --git a/src/shared/remote-workspace-session-projection.ts b/src/shared/remote-workspace-session-projection.ts index 3b60d9044ff..32ac8759527 100644 --- a/src/shared/remote-workspace-session-projection.ts +++ b/src/shared/remote-workspace-session-projection.ts @@ -3,13 +3,21 @@ import type { RemoteWorkspaceSession, RemoteWorkspaceTerminalTab } from './remot import type { TerminalTab } from './terminal-tab-types' import type { WorkspaceSessionState } from './workspace-session-state-types' import { splitWorktreeId } from './worktree/id' +import type { ExecutionHostId } from './execution-host' +import { + composeWorktreeHostIdentity, + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from './worktree/host-qualified-identity' type ExportOptions = { - isTargetWorktree: (worktreeId: string) => boolean + isTargetWorktree: (worktreeId: string, executionHostId?: ExecutionHostId) => boolean } type ImportOptions = { resolveWorktreeId: (worktreePath: string) => string | null + /** Host owning the projected snapshot; absent preserves the legacy bare key. */ + executionHostId?: ExecutionHostId } function worktreePathFromId(worktreeId: string): string | null { @@ -70,13 +78,23 @@ export function exportRemoteWorkspaceSession( } const lastVisitedAtByWorktreePath: Record = {} - for (const [worktreeId, timestamp] of Object.entries(session.lastVisitedAtByWorktreeId ?? {})) { - if (!options.isTargetWorktree(worktreeId)) { + for (const [visitKey, timestamp] of Object.entries(session.lastVisitedAtByWorktreeId ?? {})) { + const worktreeId = isWorktreeHostIdentity(visitKey) + ? getWorktreeIdFromHostIdentity(visitKey) + : visitKey + const executionHostId = isWorktreeHostIdentity(visitKey) + ? (visitKey.slice(0, visitKey.indexOf('|')) as ExecutionHostId) + : undefined + if (!options.isTargetWorktree(worktreeId, executionHostId)) { continue } const worktreePath = worktreePathFromId(worktreeId) if (worktreePath) { - lastVisitedAtByWorktreePath[worktreePath] = timestamp + // Why max: a bare legacy key and its host-qualified twin collapse onto one path. + lastVisitedAtByWorktreePath[worktreePath] = Math.max( + lastVisitedAtByWorktreePath[worktreePath] ?? 0, + timestamp + ) } } @@ -101,7 +119,7 @@ export function exportRemoteWorkspaceSession( ) ), activeWorktreePathsOnShutdown: session.activeWorktreeIdsOnShutdown - ?.filter(options.isTargetWorktree) + ?.filter((worktreeId) => options.isTargetWorktree(worktreeId)) .map(worktreePathFromId) .filter((path): path is string => Boolean(path)), activeTabIdByWorktreePath, @@ -166,7 +184,10 @@ export function importRemoteWorkspaceSession( )) { const worktreeId = resolvePath(worktreePath) if (worktreeId) { - lastVisitedAtByWorktreeId[worktreeId] = timestamp + const key = options.executionHostId + ? composeWorktreeHostIdentity(options.executionHostId, worktreeId) + : worktreeId + lastVisitedAtByWorktreeId[key] = timestamp } } diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index d9aba06ac9d..6420f9e1e60 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -147,6 +147,8 @@ const tabSchema = z.object({ color: z.string().nullable(), sortOrder: z.number(), createdAt: z.number(), + // Why: corrupt optional recency must not discard the whole persisted tab. + lastFocusedAt: z.number().finite().nonnegative().optional().catch(undefined), isPreview: z.boolean().optional(), isPinned: z.boolean().optional(), // Why: persist the per-tab native-chat view mode so 'chat' survives reload / diff --git a/src/shared/workspace-session-state-types.ts b/src/shared/workspace-session-state-types.ts index bc20b7c5459..4b4c07ba908 100644 --- a/src/shared/workspace-session-state-types.ts +++ b/src/shared/workspace-session-state-types.ts @@ -84,7 +84,9 @@ export type WorkspaceSessionState = { * and worktreeNavHistory (Back/Forward stack). See * docs/cmd-j-empty-query-ordering.md. Absent in sessions written by * older builds — hydration tolerates missing/partial maps and the - * active worktree is seeded on first restore. */ + * active worktree is seeded on first restore. New host-qualified keys use + * `${executionHostId}|${worktreeId}`; legacy bare keys remain readable + * during migration and remote snapshot projection. */ lastVisitedAtByWorktreeId?: Record /** Worktrees whose repo-defined default terminal tabs have already been * considered. Persisted so closing all tabs and re-opening the workspace diff --git a/src/shared/workspace-session-tab-focus-schema.test.ts b/src/shared/workspace-session-tab-focus-schema.test.ts new file mode 100644 index 00000000000..9e2167d5082 --- /dev/null +++ b/src/shared/workspace-session-tab-focus-schema.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { parseWorkspaceSession } from './workspace-session-schema' + +function sessionWithLastFocusedAt(lastFocusedAt: unknown): Record { + return { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { + wt: [ + { + id: 'tab-1', + entityId: 'terminal-1', + groupId: 'group-1', + worktreeId: 'wt', + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + lastFocusedAt + } + ] + } + } +} + +describe('workspace session unified-tab focus timestamp', () => { + it('preserves a finite nonnegative lastFocusedAt', () => { + const result = parseWorkspaceSession(sessionWithLastFocusedAt(1_700_000_000_123)) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.unifiedTabs?.wt[0]?.lastFocusedAt).toBe(1_700_000_000_123) + } + }) + + it.each([-1, Number.NaN, Number.POSITIVE_INFINITY])( + 'drops an invalid lastFocusedAt without dropping its tab (%s)', + (lastFocusedAt) => { + const result = parseWorkspaceSession(sessionWithLastFocusedAt(lastFocusedAt)) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.unifiedTabs?.wt).toHaveLength(1) + expect(result.value.unifiedTabs?.wt[0]?.lastFocusedAt).toBeUndefined() + } + } + ) +}) diff --git a/src/shared/worktree/host-qualified-identity.ts b/src/shared/worktree/host-qualified-identity.ts index dcdc4411724..4f5f2fd083e 100644 --- a/src/shared/worktree/host-qualified-identity.ts +++ b/src/shared/worktree/host-qualified-identity.ts @@ -1,5 +1,4 @@ -import type { ExecutionHostId } from '../execution-host' -import { parseExecutionHostId } from '../execution-host' +import { parseExecutionHostId, type ExecutionHostId } from '../execution-host' import type { Worktree } from './types' /** @@ -67,3 +66,12 @@ export function getExecutionHostIdFromWorktreeHostIdentity( export function getWorktreeIdFromHostIdentity(identity: string): string { return identity.slice(identity.indexOf(HOST_SEPARATOR) + 1) } + +/** True only for the canonical host-qualified form, not a legacy id containing `|`. */ +export function isWorktreeHostIdentity(identity: string): boolean { + const separator = identity.indexOf(HOST_SEPARATOR) + return ( + separator === 0 || + (separator > 0 && parseExecutionHostId(identity.slice(0, separator)) !== null) + ) +} From 171c1b3e7fe595bf3ca3f8ccadf01b527e31899a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:27:57 +0000 Subject: [PATCH 11/12] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index a9566939925..1c352a1f7e5 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 27m + + downloads: 28m @@ -15,7 +15,7 @@ downloads downloads - 27m - 27m + 28m + 28m From 9d87532ecf9860488db1d915ff848ca17b79f4b2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:42:01 -0700 Subject: [PATCH 12/12] feat(workspace-cleanup): name every applied filter in the bar and make it removable (#15300) * feat(workspace-cleanup): name every applied filter in the bar and make it removable Replaces the one-time filter migration this PR used to carry, and the per-group apply checkboxes that were planned to follow it. Both existed to answer one question -- why is a filter I never turned on hiding my workspaces -- and neither was the cheapest honest answer. The bar already read "Showing 546 of 799", so the *effect* was always visible. What was missing was the *cause*: which filter, and that it came from a previous session. Active constraints now render as removable chips in the bar, and Clear filters is promoted out of the popover it was buried in. Why this replaces the migration: a blanket clear cannot tell a wheel mutation from a deliberate choice, and the version here was worse than that -- it neutralized all ten groups, including location.repoIds and location.pathPrefix, which a wheel cannot set. With a chip, a stray threshold is visible on open and one click removes it. No marker, no provenance guessing, nobody's deliberate filters deleted. Why this replaces the apply toggle: every group already has a neutral resting state that does not constrain -- an empty numeric field, tri-states at 'any', the two booleans permissive. "Not applied" and "empty" are the same state today, so the toggle's only unique power was parking a value you are not using. That is a modest convenience against ten checkboxes, two-way drafts, a persistence model that could not use an 'enabled' flag without an older host dropping it, and a hydration guard. Chips are per-field, not per-group: "Activity" tells a reader nothing, while "Idle 20d+" names the thing hiding their workspaces. Zero handling matches the matchers: a 0 minimum is inert and shows no chip, a 0 maximum hides every measured non-empty workspace and does. * fix(workspace-cleanup): address the second review on the filter chips Four findings from the re-review of 3b88645aa1: - **Same-tick writes could restore a cleared chip.** `replaceFilters` read the store but `patchFilters` still rebuilt from the render snapshot, so a chip clear plus a facet patch in one tick left `idleMinDays` at 20. Every writer now derives from current store state. Tests cover both call orders, and reverting the reader reproduces the reported failure. - **Chip labels went stale across a language change.** They were memoized on `filters` alone, so unchanged filters reused the previous language's strings. Derivation is constant-size (one pass over the filter fields, not per row), so it just runs each render. - **The new catalog entries were English-only.** ko and zh now carry all 22 chip strings. The verifier stayed green because missing target-locale entries are allowed and fall back to English -- which is exactly the trap this series has now hit twice. - **Remove targets were 16x16.** They use the shared button primitive at the canonical `icon-xs` size. Also trimmed the defect-history comments to the repo's concise style. * fix(workspace-cleanup): unify chip clears on the merged updater form #15298 landed the functional-update `patchFilters`, so `replaceFilters` uses the same idiom rather than reading the store directly. Same guarantee, one pattern. --- ...se-workspace-cleanup-browse-state.test.tsx | 23 ++ .../use-workspace-cleanup-browse-state.ts | 16 +- ...workspace-cleanup-applied-filter-chips.tsx | 180 +++++++++++++ .../workspace-cleanup-browse-toolbar.tsx | 1 + .../workspace-cleanup-filter-bar.test.tsx | 53 +++- .../workspace-cleanup-filter-bar.tsx | 21 +- src/renderer/src/i18n/locales/en.json | 48 +++- src/renderer/src/i18n/locales/ko.json | 48 ++++ src/renderer/src/i18n/locales/zh.json | 48 ++++ .../workspace-cleanup-applied-filters.test.ts | 153 +++++++++++ .../workspace-cleanup-applied-filters.ts | 255 ++++++++++++++++++ 11 files changed, 839 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx create mode 100644 src/shared/workspace-cleanup-applied-filters.test.ts create mode 100644 src/shared/workspace-cleanup-applied-filters.ts diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.test.tsx b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.test.tsx index 8e946009725..061fdf3ea9e 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.test.tsx @@ -9,6 +9,7 @@ import { resetWorkspaceCleanupBrowsePersistTimer } from '@/store/slices/workspace-cleanup-browse' import { createDefaultWorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state' +import { listAppliedWorkspaceCleanupFilters } from '../../../../shared/workspace-cleanup-applied-filters' import { useWorkspaceCleanupBrowseState } from './use-workspace-cleanup-browse-state' import type { WorkspaceCleanupBrowseController } from './use-workspace-cleanup-browse-state' @@ -104,5 +105,27 @@ describe('useWorkspaceCleanupBrowseState', () => { field: 'size', direction: 'desc' }) +}) + + it('keeps a cleared chip cleared when a facet patch lands in the same tick', () => { + const controller = mountController() + act(() => controller.current!.patchFilters('activity', { idleMinDays: 20 })) + + const format = new Proxy({}, { get: () => () => 'chip' }) as Parameters< + typeof listAppliedWorkspaceCleanupFilters + >[1] + const chip = listAppliedWorkspaceCleanupFilters( + store.getState().workspaceCleanupBrowse.filters, + format + ).find((a) => a.id === 'activity.idleMinDays')! + + act(() => { + controller.current!.replaceFilters(chip.clear) + controller.current!.patchFilters('git', { states: ['dirty'] }) + }) + + const filters = store.getState().workspaceCleanupBrowse.filters + expect(filters.activity.idleMinDays).toBeNull() + expect(filters.git.states).toEqual(['dirty']) }) }) diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts index 76f4164b694..3651f4e01a0 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.ts @@ -16,6 +16,10 @@ export type WorkspaceCleanupBrowseController = { ) => void toggleSortField: (field: WorkspaceCleanupSortField) => void clearFilters: () => void + /** Applies a whole-state transform, for callers that clear one named constraint. */ + replaceFilters: ( + transform: (filters: WorkspaceCleanupFilterState) => WorkspaceCleanupFilterState + ) => void } /** @@ -56,6 +60,15 @@ export function useWorkspaceCleanupBrowseState(): WorkspaceCleanupBrowseControll [updateBrowse] ) + // Same updater form as patchFilters: a chip clear and a facet patch can land in one + // tick, and whichever read the render snapshot would undo the other. + const replaceFilters = useCallback( + (transform) => { + updateBrowse((current) => ({ ...current, filters: transform(current.filters) })) + }, + [updateBrowse] + ) + const clearFilters = useCallback(() => { updateBrowse((current) => ({ ...current, @@ -68,6 +81,7 @@ export function useWorkspaceCleanupBrowseState(): WorkspaceCleanupBrowseControll sort: browse.sort, patchFilters, toggleSortField, - clearFilters + clearFilters, + replaceFilters } } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx new file mode 100644 index 00000000000..38d615514cc --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx @@ -0,0 +1,180 @@ +import React from 'react' +import { X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { toMegabytes } from './workspace-cleanup-facet-panel-model' +import type { WorkspaceCleanupAppliedFilter } from '../../../../shared/workspace-cleanup-applied-filters' +import { listAppliedWorkspaceCleanupFilters } from '../../../../shared/workspace-cleanup-applied-filters' +import type { WorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' + +/** Why in the bar, not the panel: a filter narrowing the list must be visible without opening anything. */ +// Why not memoized: the labels are translated at build time, so caching them on +// `filters` alone reuses the previous language's strings after a language change. +// Derivation is constant-size (one pass over the filter fields, not per row). +export function useAppliedWorkspaceCleanupFilters( + filters: WorkspaceCleanupFilterState +): WorkspaceCleanupAppliedFilter[] { + return listAppliedWorkspaceCleanupFilters(filters, buildAppliedFilterFormatters()) +} + +export function WorkspaceCleanupAppliedFilterChips({ + applied, + onClear +}: { + applied: readonly WorkspaceCleanupAppliedFilter[] + onClear: (filter: WorkspaceCleanupAppliedFilter) => void +}): React.JSX.Element | null { + if (applied.length === 0) { + return null + } + return ( +
+ {applied.map((filter) => ( + + {filter.label} + + + ))} +
+ ) +} + +function buildAppliedFilterFormatters(): Parameters[1] { + const mb = (bytes: number): number => toMegabytes(bytes) ?? 0 + return { + idleDays: (days) => + translate('components.workspace.cleanup.browse.chip.idleDays', 'Idle {{value0}}d+', { + value0: days + }), + neverVisited: () => + translate('components.workspace.cleanup.browse.chip.neverVisited', 'Never visited'), + minSize: (bytes) => + translate('components.workspace.cleanup.browse.chip.minSize', 'At least {{value0}} MB', { + value0: mb(bytes) + }), + maxSize: (bytes) => + translate('components.workspace.cleanup.browse.chip.maxSize', 'At most {{value0}} MB', { + value0: mb(bytes) + }), + excludesUnsized: () => + translate('components.workspace.cleanup.browse.chip.excludesUnsized', 'Measured only'), + excludesStatusless: () => + translate('components.workspace.cleanup.browse.chip.excludesStatusless', 'Has a status'), + list: (kind, count) => + translate('components.workspace.cleanup.browse.chip.list', '{{value0}}: {{value1}}', { + value0: getChipKindLabel(kind), + value1: count + }), + triState: (kind, mode) => + translate('components.workspace.cleanup.browse.chip.triState', '{{value0}}: {{value1}}', { + value0: getChipKindLabel(kind), + value1: + mode === 'only' + ? translate('components.workspace.cleanup.browse.chip.only', 'Only') + : translate('components.workspace.cleanup.browse.chip.exclude', 'Excluded') + }), + minAhead: (count) => + translate('components.workspace.cleanup.browse.chip.minAhead', 'Ahead {{value0}}+', { + value0: count + }), + minBehind: (count) => + translate('components.workspace.cleanup.browse.chip.minBehind', 'Behind {{value0}}+', { + value0: count + }), + branchQuery: (value) => + translate('components.workspace.cleanup.browse.chip.branchQuery', 'Branch: {{value0}}', { + value0: value + }), + pathPrefix: (value) => + translate('components.workspace.cleanup.browse.chip.pathPrefix', 'Path: {{value0}}', { + value0: value + }), + presence: (kind, mode) => + translate('components.workspace.cleanup.browse.chip.presence', '{{value0}}: {{value1}}', { + value0: getChipKindLabel(kind), + value1: + mode === 'some' + ? translate('components.workspace.cleanup.browse.chip.has', 'Has') + : translate('components.workspace.cleanup.browse.chip.none', 'None') + }), + completelyEmpty: () => + translate('components.workspace.cleanup.browse.chip.completelyEmpty', 'Nothing to lose'), + selectableOnly: () => + translate('components.workspace.cleanup.browse.chip.selectableOnly', 'Deletable only') + } +} + +function getChipKindLabel(kind: string): string { + switch (kind) { + case 'status': + return translate('components.workspace.cleanup.browse.chip.kind.status', 'Status') + case 'agent': + return translate('components.workspace.cleanup.browse.chip.kind.agent', 'Agent') + case 'git': + return translate('components.workspace.cleanup.browse.chip.kind.git', 'Git') + case 'review': + return translate('components.workspace.cleanup.browse.chip.kind.review', 'Review') + case 'reviewState': + return translate('components.workspace.cleanup.browse.chip.kind.reviewState', 'Review state') + case 'reviewProvider': + return translate('components.workspace.cleanup.browse.chip.kind.reviewProvider', 'Provider') + case 'ticket': + return translate('components.workspace.cleanup.browse.chip.kind.ticket', 'Ticket') + case 'ticketSource': + return translate('components.workspace.cleanup.browse.chip.kind.ticketSource', 'Ticket source') + case 'context': + return translate('components.workspace.cleanup.browse.chip.kind.context', 'Context') + case 'host': + return translate('components.workspace.cleanup.browse.chip.kind.host', 'Host') + case 'repo': + return translate('components.workspace.cleanup.browse.chip.kind.repo', 'Repo') + case 'blocker': + return translate('components.workspace.cleanup.browse.chip.kind.blocker', 'Blocker') + case 'tier': + return translate('components.workspace.cleanup.browse.chip.kind.tier', 'Safety') + case 'dismissed': + return translate('components.workspace.cleanup.browse.chip.kind.dismissed', 'Ignored') + case 'archived': + return translate('components.workspace.cleanup.browse.chip.kind.archived', 'Archived') + case 'pinned': + return translate('components.workspace.cleanup.browse.chip.kind.pinned', 'Pinned') + case 'unread': + return translate('components.workspace.cleanup.browse.chip.kind.unread', 'Unread') + case 'comment': + return translate('components.workspace.cleanup.browse.chip.kind.comment', 'Comment') + case 'prunable': + return translate('components.workspace.cleanup.browse.chip.kind.prunable', 'Prunable') + case 'locked': + return translate('components.workspace.cleanup.browse.chip.kind.locked', 'Locked') + case 'retainedAgents': + return translate( + 'components.workspace.cleanup.browse.chip.kind.retainedAgents', + 'Finished agents' + ) + default: + return kind + } +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx index c70209dd0eb..d53b3215c2c 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx @@ -71,6 +71,7 @@ export function WorkspaceCleanupBrowseToolbar({ gitEvidence={{ pendingCount: gitPendingCount, totalCount: gitCheckedTotal }} onQueryChange={(query) => browse.patchFilters('query', query)} onClearFilters={browse.clearFilters} + onClearAppliedFilter={(filter) => browse.replaceFilters(filter.clear)} /> + hasActiveFilters?: boolean + onClearAppliedFilter?: (filter: WorkspaceCleanupAppliedFilter) => void + onClearFilters?: () => void + } = {} +): void { render( ) } @@ -46,6 +56,41 @@ describe('WorkspaceCleanupFilterBar', () => { expect(screen.queryByRole('button', { name: 'Scan' })).toBeNull() }) + it('names an applied filter in the bar without opening the panel', () => { + // The reported defect: a persisted idleMinDays the user never set. The bar already + // read "Showing N of M", so only the cause was hidden. + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 20 + renderFilterBar(false, { filters, hasActiveFilters: true }) + + expect(screen.getByRole('listitem').textContent).toContain('Idle 20d+') + }) + + it('clears one named filter from its chip', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 20 + const onClearAppliedFilter = vi.fn() + renderFilterBar(false, { filters, hasActiveFilters: true, onClearAppliedFilter }) + + screen.getByRole('button', { name: 'Remove filter Idle 20d+' }).click() + + expect(onClearAppliedFilter).toHaveBeenCalledTimes(1) + expect(onClearAppliedFilter.mock.calls[0][0].id).toBe('activity.idleMinDays') + }) + + it('offers Clear filters in the bar, not only inside the panel', () => { + renderFilterBar(false, { hasActiveFilters: true }) + + expect(screen.getAllByRole('button', { name: /Clear filters/ }).length).toBeGreaterThan(0) + }) + + it('shows no chips and no Clear for a default profile', () => { + renderFilterBar() + + expect(screen.queryAllByRole('listitem')).toHaveLength(0) + expect(screen.queryByRole('button', { name: /Clear filters/ })).toBeNull() + }) + it('keeps the footer visible while the facet panel scrolls', () => { renderFilterBar(true) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx index 1fcfa2049dc..3f4ae9e9ab9 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx @@ -5,6 +5,11 @@ import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { ScrollArea } from '@/components/ui/scroll-area' import { translate } from '@/i18n/i18n' +import { + useAppliedWorkspaceCleanupFilters, + WorkspaceCleanupAppliedFilterChips +} from './workspace-cleanup-applied-filter-chips' +import type { WorkspaceCleanupAppliedFilter } from '../../../../shared/workspace-cleanup-applied-filters' import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-facets' import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets' import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model' @@ -23,7 +28,8 @@ export function WorkspaceCleanupFilterBar({ hasActiveFilters, gitEvidence, onQueryChange, - onClearFilters + onClearFilters, + onClearAppliedFilter }: { facetProps: WorkspaceCleanupFacetGroupProps facetPanelOpen: boolean @@ -34,8 +40,10 @@ export function WorkspaceCleanupFilterBar({ gitEvidence: WorkspaceCleanupGitEvidenceProgress onQueryChange: (query: string) => void onClearFilters: () => void + onClearAppliedFilter: (filter: WorkspaceCleanupAppliedFilter) => void }): React.JSX.Element { const { filters, totalCount } = facetProps + const applied = useAppliedWorkspaceCleanupFilters(filters) return (
@@ -101,6 +109,15 @@ export function WorkspaceCleanupFilterBar({ )} + {/* Why out here and not only in the popover: a filter narrowing the list must be + clearable without first discovering which panel it lives in. */} + {hasActiveFilters ? ( + + ) : null} + {gitEvidence.pendingCount > 0 ? ( @@ -111,6 +128,8 @@ export function WorkspaceCleanupFilterBar({ )} ) : null} + +
) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c117b08e172..3600bbeb99b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16234,7 +16234,53 @@ "selectionWithheldOne": "1 selected workspace is hidden by the current filters and was deselected.", "selectionWithheld": "{{value0}} selected workspaces are hidden by the current filters and were deselected.", "selectAllCountOne": "Select 1 safety-checked workspace", - "selectAllCount": "Select all {{value0}} safety-checked workspaces" + "selectAllCount": "Select all {{value0}} safety-checked workspaces", + "appliedFilters": "Applied filters", + "removeFilter": "Remove filter {{value0}}", + "chip": { + "idleDays": "Idle {{value0}}d+", + "neverVisited": "Never visited", + "minSize": "At least {{value0}} MB", + "maxSize": "At most {{value0}} MB", + "excludesUnsized": "Measured only", + "excludesStatusless": "Has a status", + "list": "{{value0}}: {{value1}}", + "triState": "{{value0}}: {{value1}}", + "only": "Only", + "exclude": "Excluded", + "minAhead": "Ahead {{value0}}+", + "minBehind": "Behind {{value0}}+", + "branchQuery": "Branch: {{value0}}", + "pathPrefix": "Path: {{value0}}", + "presence": "{{value0}}: {{value1}}", + "has": "Has", + "none": "None", + "completelyEmpty": "Nothing to lose", + "selectableOnly": "Deletable only", + "kind": { + "status": "Status", + "agent": "Agent", + "git": "Git", + "review": "Review", + "reviewState": "Review state", + "reviewProvider": "Provider", + "ticket": "Ticket", + "ticketSource": "Ticket source", + "context": "Context", + "host": "Host", + "repo": "Repo", + "blocker": "Blocker", + "tier": "Safety", + "dismissed": "Ignored", + "archived": "Archived", + "pinned": "Pinned", + "unread": "Unread", + "comment": "Comment", + "prunable": "Prunable", + "locked": "Locked", + "retainedAgents": "Finished agents" + } + } }, "scan": { "progress": "Scanning workspaces ({{value0}}/{{value1}})", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2384a7996fc..6164f1707bd 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14566,6 +14566,54 @@ "presentation": { "gitlabMergeRequestNumber": "MR #{{value0}}", "githubPullRequestNumber": "PR #{{value0}}" + }, + "browse": { + "appliedFilters": "적용된 필터", + "removeFilter": "{{value0}} 필터 제거", + "chip": { + "idleDays": "{{value0}}일 이상 유휴", + "neverVisited": "방문한 적 없음", + "minSize": "{{value0}} MB 이상", + "maxSize": "{{value0}} MB 이하", + "excludesUnsized": "측정된 항목만", + "excludesStatusless": "상태 있음", + "list": "{{value0}}: {{value1}}", + "triState": "{{value0}}: {{value1}}", + "only": "만", + "exclude": "제외", + "minAhead": "{{value0}}개 이상 앞섬", + "minBehind": "{{value0}}개 이상 뒤처짐", + "branchQuery": "브랜치: {{value0}}", + "pathPrefix": "경로: {{value0}}", + "presence": "{{value0}}: {{value1}}", + "has": "있음", + "none": "없음", + "completelyEmpty": "잃을 것 없음", + "selectableOnly": "삭제 가능한 항목만", + "kind": { + "status": "상태", + "agent": "에이전트", + "git": "Git", + "review": "리뷰", + "reviewState": "리뷰 상태", + "reviewProvider": "제공자", + "ticket": "티켓", + "ticketSource": "티켓 소스", + "context": "컨텍스트", + "host": "호스트", + "repo": "저장소", + "blocker": "차단 요인", + "tier": "안전", + "dismissed": "무시됨", + "archived": "보관됨", + "pinned": "고정됨", + "unread": "읽지 않음", + "comment": "댓글", + "prunable": "정리 가능", + "locked": "잠김", + "retainedAgents": "완료된 에이전트" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index fdd53f16ed9..84c05074bb5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14557,6 +14557,54 @@ "presentation": { "gitlabMergeRequestNumber": "MR #{{value0}}", "githubPullRequestNumber": "PR #{{value0}}" + }, + "browse": { + "appliedFilters": "已应用的筛选", + "removeFilter": "移除筛选 {{value0}}", + "chip": { + "idleDays": "闲置 {{value0}} 天以上", + "neverVisited": "从未访问", + "minSize": "至少 {{value0}} MB", + "maxSize": "最多 {{value0}} MB", + "excludesUnsized": "仅已测量", + "excludesStatusless": "有状态", + "list": "{{value0}}:{{value1}}", + "triState": "{{value0}}:{{value1}}", + "only": "仅", + "exclude": "排除", + "minAhead": "领先 {{value0}} 个以上", + "minBehind": "落后 {{value0}} 个以上", + "branchQuery": "分支:{{value0}}", + "pathPrefix": "路径:{{value0}}", + "presence": "{{value0}}:{{value1}}", + "has": "有", + "none": "无", + "completelyEmpty": "没有可丢失的内容", + "selectableOnly": "仅可删除", + "kind": { + "status": "状态", + "agent": "代理", + "git": "Git", + "review": "审查", + "reviewState": "审查状态", + "reviewProvider": "提供方", + "ticket": "工单", + "ticketSource": "工单来源", + "context": "上下文", + "host": "主机", + "repo": "仓库", + "blocker": "阻塞项", + "tier": "安全", + "dismissed": "已忽略", + "archived": "已归档", + "pinned": "已固定", + "unread": "未读", + "comment": "评论", + "prunable": "可修剪", + "locked": "已锁定", + "retainedAgents": "已完成的代理" + } + } } } }, diff --git a/src/shared/workspace-cleanup-applied-filters.test.ts b/src/shared/workspace-cleanup-applied-filters.test.ts new file mode 100644 index 00000000000..9cd9fd4afb8 --- /dev/null +++ b/src/shared/workspace-cleanup-applied-filters.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { createDefaultWorkspaceCleanupFilterState } from './workspace-cleanup-filter-model' +import { listAppliedWorkspaceCleanupFilters } from './workspace-cleanup-applied-filters' + +const FORMAT = { + idleDays: (d: number) => `Idle >= ${d}d`, + neverVisited: () => 'Never visited', + minSize: (b: number) => `At least ${b}B`, + maxSize: (b: number) => `At most ${b}B`, + excludesUnsized: () => 'Excludes unmeasured', + excludesStatusless: () => 'Excludes statusless', + list: (kind: string, count: number) => `${kind}:${count}`, + triState: (kind: string, mode: string) => `${kind}:${mode}`, + minAhead: (n: number) => `Ahead >= ${n}`, + minBehind: (n: number) => `Behind >= ${n}`, + branchQuery: (v: string) => `Branch ~ ${v}`, + pathPrefix: (v: string) => `Path ~ ${v}`, + presence: (kind: string, mode: string) => `${kind}:${mode}`, + completelyEmpty: () => 'Nothing to lose', + selectableOnly: () => 'Deletable only' +} + +const list = (f: ReturnType) => + listAppliedWorkspaceCleanupFilters(f, FORMAT) + +describe('listAppliedWorkspaceCleanupFilters', () => { + it('shows nothing for a default profile', () => { + expect(list(createDefaultWorkspaceCleanupFilterState())).toEqual([]) + }) + + it('names the reported stuck filter rather than its group', () => { + // The bar always read "Showing 546 of 799"; what was missing was which filter. + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 20 + + const applied = list(filters) + + expect(applied).toHaveLength(1) + expect(applied[0].label).toBe('Idle >= 20d') + }) + + it('clears one constraint and leaves the rest of its own group alone', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 20 + filters.activity.neverVisited = true + + const idle = list(filters).find((a) => a.id === 'activity.idleMinDays') + const next = idle!.clear(filters) + + expect(next.activity.idleMinDays).toBeNull() + expect(next.activity.neverVisited).toBe(true) + }) + + it('never mutates the filters it is handed', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.location.pathPrefix = '/repos' + + list(filters)[0].clear(filters) + + expect(filters.location.pathPrefix).toBe('/repos') + }) + + describe('numeric zero', () => { + it('hides an inert zero minimum, which matches every row', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 0 + filters.size.minBytes = 0 + filters.git.minAhead = 0 + filters.git.minBehind = 0 + + expect(list(filters)).toEqual([]) + }) + + it('shows a zero maximum, which hides every measured non-empty workspace', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.size.maxBytes = 0 + + expect(list(filters).map((a) => a.id)).toEqual(['size.maxBytes']) + }) + }) + + it('ignores whitespace-only text', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.git.branchQuery = ' ' + filters.location.pathPrefix = ' ' + + expect(list(filters)).toEqual([]) + }) + + it('ignores parameter fields that constrain nothing on their own', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleSignal = 'created' + filters.safety.blockerMode = 'any-of' + + expect(list(filters)).toEqual([]) + }) + + it('shows the permissive booleans only when switched off', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.size.includeUnsized = false + filters.status.matchStatusless = false + + expect(list(filters).map((a) => a.id).sort()).toEqual([ + 'size.includeUnsized', + 'status.matchStatusless' + ]) + }) + + it('gives every constraint a unique, stable id', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 20 + filters.size.maxBytes = 500 + filters.status.archived = 'only' + filters.git.states = ['dirty'] + filters.git.locked = 'exclude' + filters.location.repoIds = ['repo-1'] + filters.safety.dismissed = 'only' + + const ids = list(filters).map((a) => a.id) + + expect(new Set(ids).size).toBe(ids.length) + expect(ids).toContain('status.archived') + expect(ids).toContain('git.locked') + }) + + it('clearing every chip in turn returns the profile to no constraints', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.activity.idleMinDays = 20 + filters.size.maxBytes = 0 + filters.size.includeUnsized = false + filters.status.matchStatusless = false + filters.git.branchQuery = 'release' + filters.location.pathPrefix = '/repos' + filters.safety.selectableOnly = true + + let next = filters + for (const applied of list(filters)) { + next = applied.clear(next) + } + + expect(list(next)).toEqual([]) + }) + + it('leaves the search query alone, which chips do not own', () => { + const filters = createDefaultWorkspaceCleanupFilterState() + filters.query = 'release' + filters.activity.idleMinDays = 20 + + const next = list(filters)[0].clear(filters) + + expect(next.query).toBe('release') + }) +}) diff --git a/src/shared/workspace-cleanup-applied-filters.ts b/src/shared/workspace-cleanup-applied-filters.ts new file mode 100644 index 00000000000..449a55f088f --- /dev/null +++ b/src/shared/workspace-cleanup-applied-filters.ts @@ -0,0 +1,255 @@ +import { + createDefaultWorkspaceCleanupFilterState, + type WorkspaceCleanupFilterState +} from './workspace-cleanup-filter-model' + +/** + * One constraint the user can see and remove from the filter bar. + * + * Deliberately per-field, not per-group: "Activity" tells a reader nothing, while + * "Idle >= 20d" names the thing that is hiding their workspaces. That naming *is* the + * fix — the reported defect was never that the effect was invisible (the bar always + * read "Showing 546 of 799"), it was that the cause was. + */ +export type WorkspaceCleanupAppliedFilter = { + /** Stable across renders and unique per constraint. */ + id: string + /** Rendered on the chip. Already localized by the caller-supplied formatter. */ + label: string + /** Returns `filters` with just this constraint reset to its default. */ + clear: (filters: WorkspaceCleanupFilterState) => WorkspaceCleanupFilterState +} + +type Formatters = { + idleDays: (days: number) => string + neverVisited: () => string + minSize: (bytes: number) => string + maxSize: (bytes: number) => string + excludesUnsized: () => string + excludesStatusless: () => string + list: (kind: string, count: number) => string + triState: (kind: string, mode: 'only' | 'exclude') => string + minAhead: (count: number) => string + minBehind: (count: number) => string + branchQuery: (value: string) => string + pathPrefix: (value: string) => string + presence: (kind: string, mode: string) => string + completelyEmpty: () => string + selectableOnly: () => string +} + +/** + * Every constraint currently narrowing the fleet, in panel order. + * + * A field counts only when its matcher actually narrows. That is why minimums count + * above zero but `size.maxBytes` counts *at* zero: a `0` minimum matches every row, + * while "at most 0 MB" hides every measured non-empty workspace. + */ +export function listAppliedWorkspaceCleanupFilters( + filters: WorkspaceCleanupFilterState, + format: Formatters +): WorkspaceCleanupAppliedFilter[] { + const defaults = createDefaultWorkspaceCleanupFilterState() + const applied: WorkspaceCleanupAppliedFilter[] = [] + const add = ( + id: string, + label: string, + clear: (f: WorkspaceCleanupFilterState) => WorkspaceCleanupFilterState + ): void => { + applied.push({ id, label, clear }) + } + + const { activity, size, status, agent, git, review, ticket, context, location, safety } = filters + + if (activity.idleMinDays !== null && activity.idleMinDays > 0) { + add('activity.idleMinDays', format.idleDays(activity.idleMinDays), (f) => ({ + ...f, + activity: { ...f.activity, idleMinDays: null } + })) + } + if (activity.neverVisited) { + add('activity.neverVisited', format.neverVisited(), (f) => ({ + ...f, + activity: { ...f.activity, neverVisited: false } + })) + } + + if (size.minBytes !== null && size.minBytes > 0) { + add('size.minBytes', format.minSize(size.minBytes), (f) => ({ + ...f, + size: { ...f.size, minBytes: null } + })) + } + if (size.maxBytes !== null) { + add('size.maxBytes', format.maxSize(size.maxBytes), (f) => ({ + ...f, + size: { ...f.size, maxBytes: null } + })) + } + if (!size.includeUnsized) { + add('size.includeUnsized', format.excludesUnsized(), (f) => ({ + ...f, + size: { ...f.size, includeUnsized: true } + })) + } + + if (status.workspaceStatuses.length > 0) { + add('status.workspaceStatuses', format.list('status', status.workspaceStatuses.length), (f) => ({ + ...f, + status: { ...f.status, workspaceStatuses: [] } + })) + } + if (!status.matchStatusless) { + add('status.matchStatusless', format.excludesStatusless(), (f) => ({ + ...f, + status: { ...f.status, matchStatusless: true } + })) + } + for (const key of ['archived', 'pinned', 'unread', 'comment'] as const) { + const value = status[key] + if (value !== 'any') { + add(`status.${key}`, format.triState(key, value), (f) => ({ + ...f, + status: { ...f.status, [key]: 'any' } + })) + } + } + + if (agent.states.length > 0) { + add('agent.states', format.list('agent', agent.states.length), (f) => ({ + ...f, + agent: { ...f.agent, states: [] } + })) + } + if (agent.retainedDoneAgents !== 'any') { + add('agent.retainedDoneAgents', format.triState('retainedAgents', agent.retainedDoneAgents), (f) => ({ + ...f, + agent: { ...f.agent, retainedDoneAgents: 'any' } + })) + } + + if (git.states.length > 0) { + add('git.states', format.list('git', git.states.length), (f) => ({ + ...f, + git: { ...f.git, states: [] } + })) + } + if (git.minAhead !== null && git.minAhead > 0) { + add('git.minAhead', format.minAhead(git.minAhead), (f) => ({ + ...f, + git: { ...f.git, minAhead: null } + })) + } + if (git.minBehind !== null && git.minBehind > 0) { + add('git.minBehind', format.minBehind(git.minBehind), (f) => ({ + ...f, + git: { ...f.git, minBehind: null } + })) + } + if (git.branchQuery.trim().length > 0) { + add('git.branchQuery', format.branchQuery(git.branchQuery.trim()), (f) => ({ + ...f, + git: { ...f.git, branchQuery: '' } + })) + } + for (const key of ['prunable', 'locked'] as const) { + const value = git[key] + if (value !== 'any') { + add(`git.${key}`, format.triState(key, value), (f) => ({ + ...f, + git: { ...f.git, [key]: 'any' } + })) + } + } + + if (review.presence !== 'any') { + add('review.presence', format.presence('review', review.presence), (f) => ({ + ...f, + review: { ...f.review, presence: defaults.review.presence } + })) + } + if (review.states.length > 0) { + add('review.states', format.list('reviewState', review.states.length), (f) => ({ + ...f, + review: { ...f.review, states: [] } + })) + } + if (review.providers.length > 0) { + add('review.providers', format.list('reviewProvider', review.providers.length), (f) => ({ + ...f, + review: { ...f.review, providers: [] } + })) + } + + if (ticket.presence !== 'any') { + add('ticket.presence', format.presence('ticket', ticket.presence), (f) => ({ + ...f, + ticket: { ...f.ticket, presence: defaults.ticket.presence } + })) + } + if (ticket.sources.length > 0) { + add('ticket.sources', format.list('ticketSource', ticket.sources.length), (f) => ({ + ...f, + ticket: { ...f.ticket, sources: [] } + })) + } + + if (context.presence !== 'any') { + add('context.presence', format.presence('context', context.presence), (f) => ({ + ...f, + context: { ...f.context, presence: defaults.context.presence } + })) + } + if (context.completelyEmpty) { + add('context.completelyEmpty', format.completelyEmpty(), (f) => ({ + ...f, + context: { ...f.context, completelyEmpty: false } + })) + } + + if (location.hostIds.length > 0) { + add('location.hostIds', format.list('host', location.hostIds.length), (f) => ({ + ...f, + location: { ...f.location, hostIds: [] } + })) + } + if (location.repoIds.length > 0) { + add('location.repoIds', format.list('repo', location.repoIds.length), (f) => ({ + ...f, + location: { ...f.location, repoIds: [] } + })) + } + if (location.pathPrefix.trim().length > 0) { + add('location.pathPrefix', format.pathPrefix(location.pathPrefix.trim()), (f) => ({ + ...f, + location: { ...f.location, pathPrefix: '' } + })) + } + + if (safety.blockers.length > 0) { + add('safety.blockers', format.list('blocker', safety.blockers.length), (f) => ({ + ...f, + safety: { ...f.safety, blockers: [] } + })) + } + if (safety.tiers.length > 0) { + add('safety.tiers', format.list('tier', safety.tiers.length), (f) => ({ + ...f, + safety: { ...f.safety, tiers: [] } + })) + } + if (safety.dismissed !== 'any') { + add('safety.dismissed', format.triState('dismissed', safety.dismissed), (f) => ({ + ...f, + safety: { ...f.safety, dismissed: 'any' } + })) + } + if (safety.selectableOnly) { + add('safety.selectableOnly', format.selectableOnly(), (f) => ({ + ...f, + safety: { ...f.safety, selectableOnly: false } + })) + } + + return applied +}