diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index a7b8fe1814b..b792dd9cf47 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1,6 +1,6 @@ name: Cut Release -# Why: single entry point for manually and scheduled cutting releases. +# Why: single entry point for manually cutting releases. # Replaces the old local `pnpm release:*` scripts and the standalone scheduled # RC workflow so releases are always reproducible from CI and can never be # accidentally tagged against an uncommitted or non-main working tree. @@ -37,16 +37,10 @@ on: type: string default: main dry_run: - description: Validate a scheduled-RC run without creating a tag + description: Validate an RC release cut without creating a tag required: false default: false type: boolean - schedule: - # Why: GitHub scheduled workflows are not guaranteed to start exactly on - # time and can be delayed or dropped around high-load periods. We retry - # across the full target hour and dedupe per PT slot. Cron is UTC, so run - # during both PST and PDT equivalents and let the PT-hour gate decide. - - cron: '*/5 10,11,22,23 * * *' permissions: contents: write @@ -182,8 +176,8 @@ jobs: - name: Publish complete release-cut RC drafts from prior runs id: publish_drafts - # Why: this must run even when the current slot already cut a tag; a - # later cron retry may be the first chance to unstick a complete RC draft. + # Why: a manual RC dispatch should unstick any complete RC draft before + # deciding whether to cut another tag. if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index f83dbfcb7ae..ff7c9fd7520 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.3m + + downloads: 3.4m @@ -15,7 +15,7 @@ downloads downloads - 3.3m - 3.3m + 3.4m + 3.4m diff --git a/docs/assets/wechat-qr.png b/docs/assets/wechat-qr.png index 3c71c4952ea..bf270d857e8 100644 Binary files a/docs/assets/wechat-qr.png and b/docs/assets/wechat-qr.png differ diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 52cf23f2706..630cf497921 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index a65661afee1..df6adecb5da 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index f62e14648ab..a2e113f090d 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) +- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 46ff846a7ee..1831f10e4c0 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) --- diff --git a/mobile/app.json b/mobile/app.json index 3a09a5531c8..e957cefbabe 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.20", + "version": "0.0.22", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 3feb30faa3d..a934334b8f2 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -96,9 +96,7 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target' import { clearTerminalLiveInputFocusTimer, - defaultTerminalLiveInputHandles, isTerminalLiveInputWithinByteLimit, - pruneTerminalLiveInputHandles, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' @@ -165,6 +163,7 @@ import { } from '../../../../src/session/mobile-new-tab-agent-options' import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment' import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste' +import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference' import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions' import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind' @@ -896,11 +895,14 @@ export default function SessionScreen() { const [terminalLinkOpenMode, setTerminalLinkOpenMode] = useState('orca-browser') const [liveInputCapture, setLiveInputCapture] = useState('') - const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( - () => new Set() - ) - const liveInputTerminalHandlesRef = useRef>(new Set()) - const defaultedLiveInputTerminalHandlesRef = useRef>(new Set()) + const { + clearTerminalLiveInputDefault, + defaultTerminalHandlesToLiveInput, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + pruneTerminalHandlesFromLiveInput, + toggleTerminalLiveInput + } = useTerminalLiveInputModePreference({ hostId, worktreeId }) const [activeHandle, setActiveHandle] = useState(null) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) @@ -1104,7 +1106,6 @@ export default function SessionScreen() { sessionTabsRef.current = sessionTabs activeSessionTabIdRef.current = activeSessionTabId markdownDocsRef.current = markdownDocs - liveInputTerminalHandlesRef.current = liveInputTerminalHandles const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState( createWarningState, initialCreateWarning @@ -1170,52 +1171,6 @@ export default function SessionScreen() { [clearToastHideTimer] ) - // Why: direct input is now the mobile default, but only once per discovered - // handle so a user's buffered-mode toggle survives tab/list refreshes. - const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => { - const result = defaultTerminalLiveInputHandles( - liveInputTerminalHandlesRef.current, - defaultedLiveInputTerminalHandlesRef.current, - handles - ) - if (!result.changed) { - return - } - const nextEnabledHandles = new Set(result.enabledHandles) - const nextDefaultedHandles = new Set(result.defaultedHandles) - liveInputTerminalHandlesRef.current = nextEnabledHandles - defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles - setLiveInputTerminalHandles(nextEnabledHandles) - }, []) - - const pruneTerminalHandlesFromLiveInput = useCallback((liveHandles: ReadonlySet) => { - const result = pruneTerminalLiveInputHandles( - liveInputTerminalHandlesRef.current, - defaultedLiveInputTerminalHandlesRef.current, - liveHandles - ) - if (!result.changed) { - return - } - const nextEnabledHandles = new Set(result.enabledHandles) - const nextDefaultedHandles = new Set(result.defaultedHandles) - liveInputTerminalHandlesRef.current = nextEnabledHandles - defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles - setLiveInputTerminalHandles(nextEnabledHandles) - }, []) - - const clearTerminalLiveInputDefault = useCallback( - (handle: string) => { - const liveHandles = new Set([ - ...liveInputTerminalHandlesRef.current, - ...defaultedLiveInputTerminalHandlesRef.current - ]) - liveHandles.delete(handle) - pruneTerminalHandlesFromLiveInput(liveHandles) - }, - [pruneTerminalHandlesFromLiveInput] - ) - const dictation = useMobileDictation({ client, enabled: canSend, @@ -2685,9 +2640,6 @@ export default function SessionScreen() { setSessionTabs([]) setActiveSessionTabId(null) clearPendingLiveInputCommit() - liveInputTerminalHandlesRef.current = new Set() - defaultedLiveInputTerminalHandlesRef.current = new Set() - setLiveInputTerminalHandles(new Set()) setMarkdownDocs(new Map()) setFileDocs(new Map()) clearDelayedActionTimers() @@ -3362,17 +3314,7 @@ export default function SessionScreen() { if (!activeHandle) { return } - const nextEnabled = !liveInputTerminalHandles.has(activeHandle) - setLiveInputTerminalHandles((prev) => { - const next = new Set(prev) - if (nextEnabled) { - next.add(activeHandle) - } else { - next.delete(activeHandle) - } - liveInputTerminalHandlesRef.current = next - return next - }) + const nextEnabled = toggleTerminalLiveInput(activeHandle) clearPendingLiveInputCommit() if (nextEnabled) { scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) @@ -3380,7 +3322,7 @@ export default function SessionScreen() { clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef) liveInputRef.current?.blur() } - }, [activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles]) + }, [activeHandle, clearPendingLiveInputCommit, toggleTerminalLiveInput]) const allowTerminalGestureInput = useCallback( (handle: string, sequenceCount: number): boolean => { diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.test.ts b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts new file mode 100644 index 00000000000..d95fab23698 --- /dev/null +++ b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts @@ -0,0 +1,138 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles, + type DisabledTerminalLiveInputHandlesPreference +} from '../storage/preferences' +import { useTerminalLiveInputModePreference } from './use-terminal-live-input-mode-preference' + +vi.mock('../storage/preferences', () => ({ + readDisabledTerminalLiveInputHandlesPreference: vi.fn(), + saveDisabledTerminalLiveInputHandles: vi.fn() +})) + +type TerminalLiveInputModePreferenceHarness = { + readonly current: ReturnType + readonly unmount: () => void +} + +type Deferred = { + readonly promise: Promise + readonly resolve: (value: T) => void +} + +function createDeferred(): Deferred { + let resolve: ((value: T) => void) | null = null + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + if (!resolve) { + throw new Error('deferred resolver was not initialized') + } + return { promise, resolve } +} + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness { + let current: ReturnType | null = null + let renderer: ReactTestRenderer | null = null + + function Harness(): null { + current = useTerminalLiveInputModePreference({ + hostId: 'host-1', + worktreeId: 'worktree-1' + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(Harness)) + }) + } finally { + restoreConsoleError() + } + if (!current || !renderer) { + throw new Error('terminal live input mode preference hook did not render') + } + + return { + get current() { + if (!current) { + throw new Error('terminal live input mode preference hook is not mounted') + } + return current + }, + unmount: () => { + act(() => renderer?.unmount()) + } + } +} + +describe('terminal live input mode preference hook', () => { + beforeEach(() => { + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReset() + vi.mocked(saveDisabledTerminalLiveInputHandles).mockReset() + vi.mocked(saveDisabledTerminalLiveInputHandles).mockResolvedValue() + }) + + it('merges pre-hydration edits with loaded disabled handles', async () => { + const load = createDeferred() + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise) + const harness = createTerminalLiveInputModePreferenceHarness() + + act(() => { + harness.current.defaultTerminalHandlesToLiveInput(['pty-1', 'pty-2']) + }) + act(() => { + expect(harness.current.toggleTerminalLiveInput('pty-1')).toBe(true) + }) + + await act(async () => { + load.resolve({ handles: new Set(['pty-2']), loaded: true }) + await load.promise + }) + + expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1']) + expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledTimes(1) + expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledWith( + 'host-1', + 'worktree-1', + new Set(['pty-2']) + ) + harness.unmount() + }) + + it('does not persist fallback-empty storage reads during clean hydration', async () => { + const load = createDeferred() + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise) + const harness = createTerminalLiveInputModePreferenceHarness() + + act(() => { + harness.current.defaultTerminalHandlesToLiveInput(['pty-1']) + }) + + await act(async () => { + load.resolve({ handles: new Set(), loaded: false }) + await load.promise + }) + + expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1']) + expect(saveDisabledTerminalLiveInputHandles).not.toHaveBeenCalled() + harness.unmount() + }) +}) diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.ts b/mobile/src/session/use-terminal-live-input-mode-preference.ts new file mode 100644 index 00000000000..dd59449b816 --- /dev/null +++ b/mobile/src/session/use-terminal-live-input-mode-preference.ts @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles +} from '../storage/preferences' +import { + applyDisabledTerminalLiveInputHandles, + defaultTerminalLiveInputHandles, + filterTerminalLiveInputDefaultCandidates, + pruneTerminalLiveInputHandles +} from '../terminal/terminal-live-input' + +type UseTerminalLiveInputModePreferenceOptions = { + readonly hostId: string + readonly worktreeId: string +} + +export function useTerminalLiveInputModePreference({ + hostId, + worktreeId +}: UseTerminalLiveInputModePreferenceOptions) { + const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( + () => new Set() + ) + const liveInputTerminalHandlesRef = useRef>(new Set()) + const defaultedLiveInputTerminalHandlesRef = useRef>(new Set()) + const disabledLiveInputTerminalHandlesRef = useRef>(new Set()) + const disabledLiveInputHydratedRef = useRef(false) + const pendingDisabledLiveInputHydrationEditsRef = useRef>(new Map()) + const pendingLiveInputDefaultHandlesRef = useRef>(new Set()) + + const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => { + // Why: terminal discovery (tab snapshots, list poll, create) can arrive + // before the async persisted-disabled load on worktree re-entry. + if (!disabledLiveInputHydratedRef.current) { + for (const handle of handles) { + pendingLiveInputDefaultHandlesRef.current.add(handle) + } + return + } + const defaultableHandles = filterTerminalLiveInputDefaultCandidates( + handles, + disabledLiveInputTerminalHandlesRef.current + ) + const result = defaultTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + defaultableHandles + ) + if (!result.changed) { + return + } + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + }, []) + + const persistDisabledLiveInputHandles = useCallback(() => { + void saveDisabledTerminalLiveInputHandles( + hostId, + worktreeId, + disabledLiveInputTerminalHandlesRef.current + ).catch(() => {}) + }, [hostId, worktreeId]) + + const pruneTerminalHandlesFromLiveInput = useCallback( + (liveHandles: ReadonlySet) => { + const result = pruneTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + liveHandles + ) + let prunedDisabledHandles = false + for (const handle of disabledLiveInputTerminalHandlesRef.current) { + if (liveHandles.has(handle)) { + continue + } + disabledLiveInputTerminalHandlesRef.current.delete(handle) + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false) + } + prunedDisabledHandles = true + } + if (prunedDisabledHandles && disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + if (!result.changed) { + return + } + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + }, + [persistDisabledLiveInputHandles] + ) + + const clearTerminalLiveInputDefault = useCallback( + (handle: string) => { + const liveHandles = new Set([ + ...liveInputTerminalHandlesRef.current, + ...defaultedLiveInputTerminalHandlesRef.current + ]) + liveHandles.delete(handle) + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false) + } + if (disabledLiveInputTerminalHandlesRef.current.delete(handle)) { + if (disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + } + pruneTerminalHandlesFromLiveInput(liveHandles) + }, + [persistDisabledLiveInputHandles, pruneTerminalHandlesFromLiveInput] + ) + + const toggleTerminalLiveInput = useCallback( + (handle: string): boolean => { + const nextEnabled = !liveInputTerminalHandlesRef.current.has(handle) + if (nextEnabled) { + disabledLiveInputTerminalHandlesRef.current.delete(handle) + } else { + disabledLiveInputTerminalHandlesRef.current.add(handle) + } + // Why: pre-hydration edits must patch the loaded set per handle; replacing + // the loaded set would erase other persisted opt-outs for this worktree. + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, !nextEnabled) + } + // Why: only persist after hydration; an earlier write would use the + // reset-empty ref and overwrite other handles' opt-outs for the worktree. + if (disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + setLiveInputTerminalHandles((prev) => { + const next = new Set(prev) + if (nextEnabled) { + next.add(handle) + } else { + next.delete(handle) + } + liveInputTerminalHandlesRef.current = next + return next + }) + return nextEnabled + }, + [persistDisabledLiveInputHandles] + ) + + useEffect(() => { + liveInputTerminalHandlesRef.current = new Set() + defaultedLiveInputTerminalHandlesRef.current = new Set() + disabledLiveInputTerminalHandlesRef.current = new Set() + disabledLiveInputHydratedRef.current = false + pendingDisabledLiveInputHydrationEditsRef.current = new Map() + pendingLiveInputDefaultHandlesRef.current = new Set() + setLiveInputTerminalHandles(new Set()) + + let disposed = false + // Why: load the persisted opt-outs first so defaulting logic (which can + // fire immediately from subscriptions) respects prior user choices. + void readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId).then((preference) => { + if (disposed) { + return + } + const pendingEdits = pendingDisabledLiveInputHydrationEditsRef.current + const hydratedDisabledHandles = new Set(preference.handles) + for (const [handle, disabled] of pendingEdits) { + if (disabled) { + hydratedDisabledHandles.add(handle) + } else { + hydratedDisabledHandles.delete(handle) + } + } + disabledLiveInputTerminalHandlesRef.current = hydratedDisabledHandles + disabledLiveInputHydratedRef.current = true + pendingDisabledLiveInputHydrationEditsRef.current = new Map() + if (preference.loaded && pendingEdits.size > 0) { + persistDisabledLiveInputHandles() + } + const result = applyDisabledTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + hydratedDisabledHandles + ) + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + const pendingDefaultHandles = [...pendingLiveInputDefaultHandlesRef.current] + pendingLiveInputDefaultHandlesRef.current.clear() + defaultTerminalHandlesToLiveInput(pendingDefaultHandles) + }) + return () => { + disposed = true + } + }, [defaultTerminalHandlesToLiveInput, hostId, persistDisabledLiveInputHandles, worktreeId]) + + return { + clearTerminalLiveInputDefault, + defaultTerminalHandlesToLiveInput, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + pruneTerminalHandlesFromLiveInput, + toggleTerminalLiveInput + } +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index c0f8596900a..1e5aee95de1 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -8,9 +8,12 @@ import { HOST_SIDEBAR_MIN_WIDTH, clampHostDockWidth, clampHostSidebarWidth, + loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, loadTerminalAutocompleteEnabled, loadTerminalLinkOpenMode, + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode @@ -63,6 +66,62 @@ describe('terminal autocomplete preference', () => { }) }) +describe('terminal live input disabled handles preference', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + it('defaults to no disabled handles when unset', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + await expect( + readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1') + ).resolves.toEqual({ handles: new Set(), loaded: true }) + }) + + it('loads only string terminal handles from storage', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['pty-1', 42, 'pty-2'])) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set(['pty-1', 'pty-2']) + ) + }) + + it('falls back to no disabled handles for invalid or unreadable storage', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json') + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + + vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable')) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + await expect( + readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1') + ).resolves.toEqual({ handles: new Set(), loaded: false }) + }) + + it('persists disabled handles per host and worktree', async () => { + await saveDisabledTerminalLiveInputHandles( + 'host/one', + 'folder:C:\\repo', + new Set(['pty-2', 'pty-1']) + ) + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:terminalLiveInputDisabled:host%2Fone:folder%3AC%3A%5Crepo', + JSON.stringify(['pty-2', 'pty-1']) + ) + }) +}) + describe('host sidebar width preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 5151397a7f0..d5b0db3a6e7 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -73,6 +73,53 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled)) } +const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:' + +export type DisabledTerminalLiveInputHandlesPreference = { + readonly handles: Set + readonly loaded: boolean +} + +function terminalLiveInputDisabledKey(hostId: string, worktreeId: string): string { + return `${TERMINAL_LIVE_INPUT_DISABLED_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent( + worktreeId + )}` +} + +export async function readDisabledTerminalLiveInputHandlesPreference( + hostId: string, + worktreeId: string +): Promise { + try { + const raw = await AsyncStorage.getItem(terminalLiveInputDisabledKey(hostId, worktreeId)) + if (!raw) { + return { handles: new Set(), loaded: true } + } + return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true } + } catch { + return { handles: new Set(), loaded: false } + } +} + +export async function loadDisabledTerminalLiveInputHandles( + hostId: string, + worktreeId: string +): Promise> { + const preference = await readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId) + return preference.handles +} + +export async function saveDisabledTerminalLiveInputHandles( + hostId: string, + worktreeId: string, + handles: ReadonlySet +): Promise { + await AsyncStorage.setItem( + terminalLiveInputDisabledKey(hostId, worktreeId), + JSON.stringify([...handles]) + ) +} + const SIDEBAR_WIDTH_KEY = 'orca:hostSidebarWidth' // Bounds for the draggable host worktree-list sidebar on tablet/foldable diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts index db3e96236bc..95b4960f928 100644 --- a/mobile/src/terminal/terminal-live-input.test.ts +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_LIVE_INPUT_MAX_BYTES, + applyDisabledTerminalLiveInputHandles, clearTerminalLiveInputFocusTimer, defaultTerminalLiveInputHandles, + filterTerminalLiveInputDefaultCandidates, getTerminalLiveSpecialKeyBytes, isTerminalLiveInputWithinByteLimit, pruneTerminalLiveInputHandles, @@ -101,6 +103,35 @@ describe('terminal live input', () => { expect(result.defaultedHandles).toBe(defaulted) }) + it('does not default persisted buffered-mode handles back to live input on reentry', () => { + const defaultableHandles = filterTerminalLiveInputDefaultCandidates( + ['pty-1', 'pty-2'], + new Set(['pty-1']) + ) + + const result = defaultTerminalLiveInputHandles( + new Set(), + new Set(['pty-1']), + defaultableHandles + ) + + expect(defaultableHandles).toEqual(['pty-2']) + expect([...result.enabledHandles]).toEqual(['pty-2']) + expect([...result.defaultedHandles]).toEqual(['pty-1', 'pty-2']) + }) + + it('reconciles persisted buffered-mode handles with currently enabled live input', () => { + const result = applyDisabledTerminalLiveInputHandles( + new Set(['pty-1', 'pty-2']), + new Set(['pty-2']), + new Set(['pty-1']) + ) + + expect(result.changed).toBe(true) + expect([...result.enabledHandles]).toEqual(['pty-2']) + expect([...result.defaultedHandles]).toEqual(['pty-2', 'pty-1']) + }) + it('prunes terminal handles that disappear from session snapshots', () => { const result = pruneTerminalLiveInputHandles( new Set(['pty-1', 'pty-stale']), diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts index dd5f8e6a709..96f279a4855 100644 --- a/mobile/src/terminal/terminal-live-input.ts +++ b/mobile/src/terminal/terminal-live-input.ts @@ -118,6 +118,48 @@ export function defaultTerminalLiveInputHandles( } } +export function filterTerminalLiveInputDefaultCandidates( + terminalHandles: readonly string[], + disabledHandles: ReadonlySet +): string[] { + return terminalHandles.filter((handle) => !disabledHandles.has(handle)) +} + +export function applyDisabledTerminalLiveInputHandles( + enabledHandles: ReadonlySet, + defaultedHandles: ReadonlySet, + disabledHandles: ReadonlySet +): TerminalLiveInputDefaultResult { + let nextEnabledHandles: Set | null = null + let nextDefaultedHandles: Set | null = null + + for (const handle of enabledHandles) { + if (!disabledHandles.has(handle)) { + continue + } + nextEnabledHandles ??= new Set(enabledHandles) + nextEnabledHandles.delete(handle) + } + + for (const handle of disabledHandles) { + if (defaultedHandles.has(handle)) { + continue + } + nextDefaultedHandles ??= new Set(defaultedHandles) + nextDefaultedHandles.add(handle) + } + + if (!nextEnabledHandles && !nextDefaultedHandles) { + return { enabledHandles, defaultedHandles, changed: false } + } + + return { + enabledHandles: nextEnabledHandles ?? enabledHandles, + defaultedHandles: nextDefaultedHandles ?? defaultedHandles, + changed: true + } +} + export function pruneTerminalLiveInputHandles( enabledHandles: ReadonlySet, defaultedHandles: ReadonlySet, diff --git a/package.json b/package.json index 82c0f8493c6..858ae4e230b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120-rc.2", + "version": "1.4.121-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 914a48578ab..363094775c7 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -106,6 +106,17 @@ describe('BrowserSessionRegistry', () => { expect(browserSessionRegistry.resolvePartition('nonexistent')).toBe(ORCA_BROWSER_PARTITION) }) + it('strictly resolves known profile partitions without downgrading unknown profiles', () => { + const profile = browserSessionRegistry.createProfile('isolated', 'Strict Resolve') + expect(profile).not.toBeNull() + + expect(browserSessionRegistry.resolveKnownPartition(null)).toBe(ORCA_BROWSER_PARTITION) + expect(browserSessionRegistry.resolveKnownPartition(undefined)).toBe(ORCA_BROWSER_PARTITION) + expect(browserSessionRegistry.resolveKnownPartition('default')).toBe(ORCA_BROWSER_PARTITION) + expect(browserSessionRegistry.resolveKnownPartition(profile!.id)).toBe(profile!.partition) + expect(browserSessionRegistry.resolveKnownPartition('missing-profile')).toBeNull() + }) + it('lists all profiles', () => { const before = browserSessionRegistry.listProfiles().length browserSessionRegistry.createProfile('isolated', 'List Test') diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index af5170d1cd9..98b944de729 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -326,6 +326,13 @@ class BrowserSessionRegistry { return this.profiles.get(profileId)?.partition ?? ORCA_BROWSER_PARTITION } + resolveKnownPartition(profileId: string | null | undefined): string | null { + if (!profileId) { + return ORCA_BROWSER_PARTITION + } + return this.profiles.get(profileId)?.partition ?? null + } + createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null { // Why: only the constructor may create the default profile. Allowing the // renderer to pass scope:'default' would create a second profile sharing diff --git a/src/main/browser/cdp-print-to-pdf.test.ts b/src/main/browser/cdp-print-to-pdf.test.ts new file mode 100644 index 00000000000..e6f2163b5dd --- /dev/null +++ b/src/main/browser/cdp-print-to-pdf.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf' + +const DEFAULT_MARGIN_INCHES = 1 / 2.54 +const TTL_MS = 5 * 60 * 1000 + +describe('buildPrintToPdfOptions', () => { + it('returns empty options for empty params', () => { + expect(buildPrintToPdfOptions({})).toEqual({}) + }) + + it('maps every well-formed field', () => { + expect( + buildPrintToPdfOptions({ + landscape: true, + displayHeaderFooter: true, + printBackground: true, + preferCSSPageSize: true, + generateTaggedPDF: true, + generateDocumentOutline: false, + scale: 1.5, + paperWidth: 8.5, + paperHeight: 11, + marginTop: 0.25, + marginBottom: 0.5, + marginLeft: 0.75, + marginRight: 1, + pageRanges: '1-2', + headerTemplate: '', + footerTemplate: '' + }) + ).toEqual({ + landscape: true, + displayHeaderFooter: true, + printBackground: true, + preferCSSPageSize: true, + generateTaggedPDF: true, + generateDocumentOutline: false, + scale: 1.5, + pageSize: { width: 8.5, height: 11 }, + margins: { marginType: 'custom', top: 0.25, bottom: 0.5, left: 0.75, right: 1 }, + pageRanges: '1-2', + headerTemplate: '', + footerTemplate: '' + }) + }) + + it('defaults omitted margin sides to 1cm when any side is set', () => { + expect(buildPrintToPdfOptions({ marginTop: 0.25 }).margins).toEqual({ + marginType: 'custom', + top: 0.25, + bottom: DEFAULT_MARGIN_INCHES, + left: DEFAULT_MARGIN_INCHES, + right: DEFAULT_MARGIN_INCHES + }) + }) + + it('drops non-finite and out-of-range numeric options', () => { + const options = buildPrintToPdfOptions({ + scale: 0, + paperWidth: Number.POSITIVE_INFINITY, + paperHeight: 11, + marginTop: Number.NaN + }) + expect(options.scale).toBeUndefined() + expect(options.pageSize).toBeUndefined() + expect(options.margins).toBeUndefined() + }) + + it('requires both paper dimensions for pageSize', () => { + expect(buildPrintToPdfOptions({ paperWidth: 8.5 }).pageSize).toBeUndefined() + }) + + it('ignores wrong-typed options', () => { + expect(buildPrintToPdfOptions({ landscape: 'yes', pageRanges: 7 })).toEqual({}) + }) +}) + +describe('CdpPdfStreamStore', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('claims only its own handles', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('pdf')) + + expect(store.ownsHandle({ handle })).toBe(true) + expect(handle).toMatch(/^orca-pdf-[\da-f-]{36}-\d+$/) + expect(store.ownsHandle({ handle: 'trace-stream' })).toBe(false) + expect(store.ownsHandle({ handle: 'orca-pdf-forged-1' })).toBe(false) + expect(store.ownsHandle({})).toBe(false) + }) + + it('mints distinct handles per stream', () => { + const store = new CdpPdfStreamStore() + expect(store.create(Buffer.from('a'))).not.toBe(store.create(Buffer.from('b'))) + }) + + it('reads sequential chunks and reports eof', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abcdef')) + + expect(store.read({ handle, size: 2 })).toEqual({ + data: Buffer.from('ab').toString('base64'), + eof: false + }) + expect(store.read({ handle })).toEqual({ + data: Buffer.from('cdef').toString('base64'), + eof: true + }) + }) + + it('honors an explicit read offset', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abcdef')) + + expect(store.read({ handle, offset: 4 })).toEqual({ + data: Buffer.from('ef').toString('base64'), + eof: true + }) + }) + + it('returns an empty eof chunk when reading past the end', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abc')) + + expect(store.read({ handle, offset: 99 })).toEqual({ data: '', eof: true }) + }) + + it('returns null for unknown handles', () => { + const store = new CdpPdfStreamStore() + expect(store.read({ handle: 'nope' })).toBeNull() + expect(store.read({})).toBeNull() + }) + + it('drops a stream on close', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abc')) + + store.close({ handle }) + expect(store.read({ handle })).toBeNull() + }) + + it('drops all streams on clear', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abc')) + + store.clear() + expect(store.read({ handle })).toBeNull() + }) + + it('evicts an abandoned stream after the TTL', () => { + vi.useFakeTimers() + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abc')) + + vi.advanceTimersByTime(TTL_MS + 1) + expect(store.read({ handle })).toBeNull() + }) + + it('refreshes the TTL on each read', () => { + vi.useFakeTimers() + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abcdef')) + + vi.advanceTimersByTime(TTL_MS - 1) + expect(store.read({ handle, size: 1 })).not.toBeNull() + vi.advanceTimersByTime(TTL_MS - 1) + expect(store.read({ handle, size: 1 })).not.toBeNull() + }) +}) diff --git a/src/main/browser/cdp-print-to-pdf.ts b/src/main/browser/cdp-print-to-pdf.ts new file mode 100644 index 00000000000..643013377d8 --- /dev/null +++ b/src/main/browser/cdp-print-to-pdf.ts @@ -0,0 +1,177 @@ +import { randomUUID } from 'node:crypto' +import type { PrintToPDFOptions } from 'electron' + +const PDF_DEFAULT_MARGIN_INCHES = 1 / 2.54 +const PDF_STREAM_CHUNK_BYTES = 1024 * 1024 +const PDF_STREAM_HANDLE_PREFIX = 'orca-pdf-' +const PDF_STREAM_TTL_MS = 5 * 60 * 1000 + +function finiteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +/** + * Translate CDP `Page.printToPDF` params into Electron `printToPDF` options. + * Only well-formed values are forwarded so a malformed param can never smuggle + * NaN/Infinity into Electron; omitted margin sides default to CDP's 1cm. + */ +export function buildPrintToPdfOptions(params: Record): PrintToPDFOptions { + const options: PrintToPDFOptions = {} + + if (typeof params.landscape === 'boolean') { + options.landscape = params.landscape + } + if (typeof params.displayHeaderFooter === 'boolean') { + options.displayHeaderFooter = params.displayHeaderFooter + } + if (typeof params.printBackground === 'boolean') { + options.printBackground = params.printBackground + } + if (typeof params.preferCSSPageSize === 'boolean') { + options.preferCSSPageSize = params.preferCSSPageSize + } + if (typeof params.generateTaggedPDF === 'boolean') { + options.generateTaggedPDF = params.generateTaggedPDF + } + if (typeof params.generateDocumentOutline === 'boolean') { + options.generateDocumentOutline = params.generateDocumentOutline + } + + const scale = finiteNumber(params.scale) + if (scale !== null && scale > 0) { + options.scale = scale + } + + const paperWidth = finiteNumber(params.paperWidth) + const paperHeight = finiteNumber(params.paperHeight) + if (paperWidth !== null && paperHeight !== null && paperWidth > 0 && paperHeight > 0) { + options.pageSize = { width: paperWidth, height: paperHeight } + } + + const marginTop = finiteNumber(params.marginTop) + const marginBottom = finiteNumber(params.marginBottom) + const marginLeft = finiteNumber(params.marginLeft) + const marginRight = finiteNumber(params.marginRight) + if ([marginTop, marginBottom, marginLeft, marginRight].some((margin) => margin !== null)) { + // CDP and Electron printToPDF both use inches; omitted CDP sides default to 1cm. + options.margins = { + marginType: 'custom', + top: marginTop ?? PDF_DEFAULT_MARGIN_INCHES, + bottom: marginBottom ?? PDF_DEFAULT_MARGIN_INCHES, + left: marginLeft ?? PDF_DEFAULT_MARGIN_INCHES, + right: marginRight ?? PDF_DEFAULT_MARGIN_INCHES + } + } + + if (typeof params.pageRanges === 'string') { + options.pageRanges = params.pageRanges + } + if (typeof params.headerTemplate === 'string') { + options.headerTemplate = params.headerTemplate + } + if (typeof params.footerTemplate === 'string') { + options.footerTemplate = params.footerTemplate + } + + return options +} + +type PdfStream = { + data: Buffer + offset: number + cleanupTimer: ReturnType +} + +export type PdfStreamChunk = { + data: string + eof: boolean +} + +/** + * Holds the PDF buffers produced for CDP `transferMode: "ReturnAsStream"` and + * serves them back through `IO.read` / `IO.close`, the path Playwright's + * `page.pdf()` uses. Handles carry a per-instance random prefix so a CDP client + * cannot forge one or collide with a real Chromium IO stream, and each buffer is + * evicted after a TTL so an abandoned stream can never leak memory. + */ +export class CdpPdfStreamStore { + private readonly streams = new Map() + private readonly handlePrefix = `${PDF_STREAM_HANDLE_PREFIX}${randomUUID()}-` + private nextId = 0 + + /** True when `params.handle` names one of this store's streams. */ + ownsHandle(params: Record): boolean { + return typeof params.handle === 'string' && params.handle.startsWith(this.handlePrefix) + } + + create(data: Buffer): string { + const handle = `${this.handlePrefix}${++this.nextId}` + this.streams.set(handle, { + data, + offset: 0, + cleanupTimer: this.scheduleCleanup(handle) + }) + return handle + } + + /** Read the next chunk, or `null` if the handle is unknown/expired. */ + read(params: Record): PdfStreamChunk | null { + const handle = typeof params.handle === 'string' ? params.handle : '' + const stream = this.streams.get(handle) + if (!stream) { + return null + } + this.refreshCleanup(handle, stream) + + const offset = finiteNumber(params.offset) + if (offset !== null) { + stream.offset = Math.max(0, Math.floor(offset)) + } + const requestedSize = finiteNumber(params.size) + const size = + requestedSize !== null && requestedSize > 0 + ? Math.floor(requestedSize) + : PDF_STREAM_CHUNK_BYTES + const start = Math.min(stream.offset, stream.data.length) + const end = Math.min(start + size, stream.data.length) + const chunk = stream.data.subarray(start, end) + stream.offset = end + + return { data: chunk.toString('base64'), eof: end >= stream.data.length } + } + + close(params: Record): void { + const handle = typeof params.handle === 'string' ? params.handle : '' + this.delete(handle) + } + + clear(): void { + for (const stream of this.streams.values()) { + clearTimeout(stream.cleanupTimer) + } + this.streams.clear() + } + + private scheduleCleanup(handle: string): ReturnType { + const cleanupTimer = setTimeout(() => { + this.delete(handle) + }, PDF_STREAM_TTL_MS) + const maybeNodeTimer = cleanupTimer as { unref?: () => void } + maybeNodeTimer.unref?.() + return cleanupTimer + } + + private refreshCleanup(handle: string, stream: PdfStream): void { + clearTimeout(stream.cleanupTimer) + stream.cleanupTimer = this.scheduleCleanup(handle) + } + + private delete(handle: string): void { + const stream = this.streams.get(handle) + if (!stream) { + return + } + clearTimeout(stream.cleanupTimer) + this.streams.delete(handle) + } +} diff --git a/src/main/browser/cdp-ws-proxy-focus-replay.test.ts b/src/main/browser/cdp-ws-proxy-focus-replay.test.ts new file mode 100644 index 00000000000..71b00cd9ff6 --- /dev/null +++ b/src/main/browser/cdp-ws-proxy-focus-replay.test.ts @@ -0,0 +1,394 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { CdpWsProxy } from './cdp-ws-proxy' +import { + connect, + createMockWebContents, + getSendCommandCalls, + getSendCommandMethods, + sendAndReceive, + type MockWebContents +} from './cdp-ws-proxy-test-harness' + +vi.mock('electron', () => ({ + webContents: { fromId: vi.fn() } +})) + +// Why: the proxy focuses the guest webContents natively before Input.insertText, +// which blurs any element a prior DOM.focus targeted. These tests pin the replay +// that re-applies that focus so text lands in the intended field. +describe('CdpWsProxy DOM.focus replay', () => { + let mock: MockWebContents + let proxy: CdpWsProxy + let endpoint: string + + beforeEach(async () => { + mock = createMockWebContents() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + proxy = new CdpWsProxy(mock.webContents as any) + endpoint = await proxy.start() + }) + + afterEach(async () => { + await proxy.stop() + }) + + it('replays DOM.focus before Input.insertText in the root session', async () => { + const client = await connect(endpoint) + + const focusResponse = await sendAndReceive(client, { + id: 14, + method: 'DOM.focus', + params: { backendNodeId: 99 } + }) + const insertResponse = await sendAndReceive(client, { + id: 15, + method: 'Input.insertText', + params: { text: 'hello' } + }) + + expect(focusResponse.id).toBe(14) + expect(insertResponse.id).toBe(15) + expect(insertResponse.result).toEqual({}) + expect(mock.webContents.focus).toHaveBeenCalledTimes(1) + expect(getSendCommandCalls(mock)).toEqual([ + ['Page.enable', {}], + ['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)], + ['DOM.focus', { backendNodeId: 99 }], + ['DOM.focus', { backendNodeId: 99 }], + ['Input.insertText', { text: 'hello' }] + ]) + client.close() + }) + + it('replays DOM.focus before Input.insertText for OOPIF sessions', async () => { + const client = await connect(endpoint) + + await sendAndReceive(client, { + id: 16, + method: 'DOM.focus', + params: { backendNodeId: 123 }, + sessionId: 'oopif-session-123' + }) + const insertResponse = await sendAndReceive(client, { + id: 17, + method: 'Input.insertText', + params: { text: 'frame text' }, + sessionId: 'oopif-session-123' + }) + + expect(insertResponse.id).toBe(17) + expect(insertResponse.result).toEqual({}) + expect(getSendCommandCalls(mock)).toEqual([ + ['Page.enable', {}], + ['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)], + ['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'], + ['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'], + ['Input.insertText', { text: 'frame text' }, 'oopif-session-123'] + ]) + client.close() + }) + + it('does not replay DOM.focus after adjacent eval traffic', async () => { + const client = await connect(endpoint) + + await sendAndReceive(client, { + id: 18, + method: 'DOM.focus', + params: { backendNodeId: 44 } + }) + await sendAndReceive(client, { + id: 19, + method: 'Runtime.callFunctionOn', + params: { functionDeclaration: '() => document.activeElement?.id' } + }) + const insertResponse = await sendAndReceive(client, { + id: 20, + method: 'Input.insertText', + params: { text: 'after eval' } + }) + + expect(insertResponse.id).toBe(20) + expect(insertResponse.result).toEqual({}) + expect(mock.webContents.focus).toHaveBeenCalledTimes(1) + expect(getSendCommandCalls(mock)).toEqual([ + ['Page.enable', {}], + ['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)], + ['DOM.focus', { backendNodeId: 44 }], + ['Runtime.callFunctionOn', { functionDeclaration: '() => document.activeElement?.id' }], + ['Input.insertText', { text: 'after eval' }] + ]) + client.close() + }) + + it('does not replay a failed DOM.focus on the next Input.insertText', async () => { + let domFocusAttempt = 0 + mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => { + const [method] = args as [string] + if (method === 'DOM.focus') { + domFocusAttempt += 1 + if (domFocusAttempt === 1) { + throw new Error('Node not found') + } + } + return {} + }) + + const client = await connect(endpoint) + + const focusResponse = await sendAndReceive(client, { + id: 21, + method: 'DOM.focus', + params: { backendNodeId: 55 } + }) + const insertResponse = await sendAndReceive(client, { + id: 22, + method: 'Input.insertText', + params: { text: 'fallback' } + }) + + expect(focusResponse).toEqual({ + id: 21, + error: { code: -32000, message: 'Node not found' } + }) + expect(insertResponse.id).toBe(22) + expect(insertResponse.result).toEqual({}) + expect(mock.webContents.focus).toHaveBeenCalledTimes(1) + expect(getSendCommandCalls(mock)).toEqual([ + ['Page.enable', {}], + ['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)], + ['DOM.focus', { backendNodeId: 55 }], + ['Input.insertText', { text: 'fallback' }] + ]) + client.close() + }) + + it('returns the replay error when the stored DOM.focus fails before Input.insertText', async () => { + let domFocusAttempt = 0 + mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => { + const [method] = args as [string] + if (method === 'DOM.focus') { + domFocusAttempt += 1 + if (domFocusAttempt === 2) { + throw new Error('Focus target went stale') + } + } + return {} + }) + + const client = await connect(endpoint) + + const focusResponse = await sendAndReceive(client, { + id: 23, + method: 'DOM.focus', + params: { backendNodeId: 77 } + }) + const insertResponse = await sendAndReceive(client, { + id: 24, + method: 'Input.insertText', + params: { text: 'blocked' } + }) + + expect(focusResponse.id).toBe(23) + expect(focusResponse.result).toEqual({}) + expect(insertResponse).toEqual({ + id: 24, + error: { code: -32000, message: 'Focus target went stale' } + }) + expect(mock.webContents.focus).toHaveBeenCalledTimes(1) + expect(getSendCommandCalls(mock)).toEqual([ + ['Page.enable', {}], + ['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)], + ['DOM.focus', { backendNodeId: 77 }], + ['DOM.focus', { backendNodeId: 77 }] + ]) + client.close() + }) + + it('still replays DOM.focus when Input.insertText is dispatched while DOM.focus is still in flight', async () => { + let resolveFocus: (v: Record) => void + const focusPromise = new Promise>((r) => { + resolveFocus = r + }) + mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => { + const [method] = args as [string] + if (method === 'DOM.focus') { + return focusPromise + } + return {} + }) + + const client = await connect(endpoint) + const responses: Record[] = [] + client.on('message', (data) => { + responses.push(JSON.parse(data.toString())) + }) + + client.send(JSON.stringify({ id: 25, method: 'DOM.focus', params: { backendNodeId: 66 } })) + await new Promise((r) => setTimeout(r, 10)) + // Why: dispatch the next message before the in-flight DOM.focus sendCommand + // resolves, reproducing the pipelining race the fix closes. + client.send( + JSON.stringify({ id: 26, method: 'Input.insertText', params: { text: 'pipelined' } }) + ) + + await new Promise((r) => setTimeout(r, 20)) + resolveFocus!({}) + await new Promise((r) => setTimeout(r, 20)) + + expect(responses).toHaveLength(2) + const focusResponse = responses.find((r) => r.id === 25) + const insertResponse = responses.find((r) => r.id === 26) + expect(focusResponse?.result).toEqual({}) + expect(insertResponse?.result).toEqual({}) + expect(getSendCommandMethods(mock)).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'DOM.focus', + 'DOM.focus', + 'Input.insertText' + ]) + client.close() + }) + + it('clears the pending DOM.focus replay when Page.bringToFront intervenes', async () => { + const client = await connect(endpoint) + + await sendAndReceive(client, { + id: 27, + method: 'DOM.focus', + params: { backendNodeId: 88 } + }) + await sendAndReceive(client, { id: 28, method: 'Page.bringToFront', params: {} }) + const insertResponse = await sendAndReceive(client, { + id: 29, + method: 'Input.insertText', + params: { text: 'no replay' } + }) + + expect(insertResponse.id).toBe(29) + expect(insertResponse.result).toEqual({}) + // Why: both Page.bringToFront and Input.insertText natively call focus(), + // independent of the (now-cleared) DOM.focus replay. + expect(mock.webContents.focus).toHaveBeenCalledTimes(2) + expect(getSendCommandMethods(mock)).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'DOM.focus', + 'Input.insertText' + ]) + client.close() + }) + + it('clears the pending DOM.focus replay when Page.captureScreenshot intervenes', async () => { + const client = await connect(endpoint) + + await sendAndReceive(client, { + id: 30, + method: 'DOM.focus', + params: { backendNodeId: 91 } + }) + await sendAndReceive(client, { id: 31, method: 'Page.captureScreenshot', params: {} }) + const insertResponse = await sendAndReceive(client, { + id: 32, + method: 'Input.insertText', + params: { text: 'no replay after screenshot' } + }) + + expect(insertResponse.id).toBe(32) + expect(insertResponse.result).toEqual({}) + expect(getSendCommandMethods(mock)).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'DOM.focus', + 'Page.captureScreenshot', + 'Input.insertText' + ]) + client.close() + }) + + it('does not replay a pending DOM.focus across a client reconnect', async () => { + const first = await connect(endpoint) + await sendAndReceive(first, { id: 33, method: 'DOM.focus', params: { backendNodeId: 12 } }) + first.close() + + // Why: a new client connection replaces the previous one; the stale focus + // stored by the departed client must not leak into the new client's insert. + const second = await connect(endpoint) + const insertResponse = await sendAndReceive(second, { + id: 34, + method: 'Input.insertText', + params: { text: 'fresh client' } + }) + + expect(insertResponse.id).toBe(34) + expect(insertResponse.result).toEqual({}) + expect(getSendCommandMethods(mock)).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'DOM.focus', + 'Input.insertText' + ]) + second.close() + }) + + it('does not replay or insert once the client disconnects mid-DOM.focus', async () => { + let resolveFocus: (v: Record) => void = () => {} + mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => { + const [method] = args as [string] + if (method === 'DOM.focus') { + return new Promise>((resolve) => { + resolveFocus = resolve + }) + } + return {} + }) + + const client = await connect(endpoint) + client.send(JSON.stringify({ id: 35, method: 'DOM.focus', params: { backendNodeId: 7 } })) + await new Promise((r) => setTimeout(r, 10)) + // Pipeline the insert while DOM.focus is still in flight, then drop the client. + client.send(JSON.stringify({ id: 36, method: 'Input.insertText', params: { text: 'gone' } })) + await new Promise((r) => setTimeout(r, 10)) + client.close() + await new Promise((r) => setTimeout(r, 10)) + + // Resolve the in-flight DOM.focus only after the client is gone. + resolveFocus({}) + await new Promise((r) => setTimeout(r, 20)) + + // Why: a disconnected client's focus replay and insert must not reach the live page. + const methods = getSendCommandMethods(mock) + expect(methods.filter((m) => m === 'DOM.focus')).toHaveLength(1) + expect(methods).not.toContain('Input.insertText') + }) + + it('does not insert once the client disconnects during the DOM.focus replay', async () => { + let domFocusCalls = 0 + let resolveReplay: (v: Record) => void = () => {} + mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => { + const [method] = args as [string] + if (method === 'DOM.focus') { + domFocusCalls += 1 + // Why: let the first DOM.focus resolve so a replay is queued, then hang the + // replay so the client can disconnect while it is in flight. + if (domFocusCalls === 2) { + return new Promise>((resolve) => { + resolveReplay = resolve + }) + } + } + return {} + }) + + const client = await connect(endpoint) + await sendAndReceive(client, { id: 37, method: 'DOM.focus', params: { backendNodeId: 9 } }) + client.send(JSON.stringify({ id: 38, method: 'Input.insertText', params: { text: 'late' } })) + await new Promise((r) => setTimeout(r, 10)) + client.close() + await new Promise((r) => setTimeout(r, 10)) + resolveReplay({}) + await new Promise((r) => setTimeout(r, 20)) + + expect(getSendCommandMethods(mock)).not.toContain('Input.insertText') + }) +}) diff --git a/src/main/browser/cdp-ws-proxy-test-harness.ts b/src/main/browser/cdp-ws-proxy-test-harness.ts new file mode 100644 index 00000000000..934c859c384 --- /dev/null +++ b/src/main/browser/cdp-ws-proxy-test-harness.ts @@ -0,0 +1,118 @@ +import { vi, type Mock } from 'vitest' +import WebSocket from 'ws' + +type DebuggerListener = (...args: unknown[]) => void + +type MockDebugger = { + isAttached: Mock<() => boolean> + attach: Mock<() => void> + detach: Mock<() => void> + sendCommand: Mock< + ( + method?: string, + params?: Record, + sessionId?: string + ) => Promise> + > + on: Mock<(event: string, handler: DebuggerListener) => void> + removeListener: Mock<(event: string, handler: DebuggerListener) => void> +} + +// Why: annotate the return explicitly so the exported inferred type stays nameable under +// composite declaration emit — otherwise the vi.fn() mocks leak @vitest/spy's Procedure +// and tsgo reports TS2883. +export type MockWebContents = { + webContents: { + debugger: MockDebugger + isDestroyed: () => boolean + focus: Mock<() => void> + printToPDF: Mock<() => Promise> + reload: Mock<() => void> + reloadIgnoringCache: Mock<() => void> + getTitle: Mock<() => string> + getURL: Mock<() => string> + } + listeners: Map + destroy: () => void + emit: (event: string, ...args: unknown[]) => void +} + +export function createMockWebContents(): MockWebContents { + const listeners = new Map() + let debuggerAttached = false + let destroyed = false + + const debuggerObj = { + isAttached: vi.fn(() => debuggerAttached), + attach: vi.fn(() => { + debuggerAttached = true + }), + detach: vi.fn(() => { + debuggerAttached = false + }), + sendCommand: vi.fn( + async (_method?: string, _params?: Record, _sessionId?: string) => ({}) + ), + on: vi.fn((event: string, handler: DebuggerListener) => { + const arr = listeners.get(event) ?? [] + arr.push(handler) + listeners.set(event, arr) + }), + removeListener: vi.fn((event: string, handler: DebuggerListener) => { + const arr = listeners.get(event) ?? [] + listeners.set( + event, + arr.filter((h) => h !== handler) + ) + }) + } + + return { + webContents: { + debugger: debuggerObj, + isDestroyed: () => destroyed, + focus: vi.fn(), + printToPDF: vi.fn(async () => Buffer.from('%PDF-test')), + reload: vi.fn(), + reloadIgnoringCache: vi.fn(), + getTitle: vi.fn(() => 'Example'), + getURL: vi.fn(() => 'https://example.com') + }, + listeners, + destroy() { + destroyed = true + }, + emit(event: string, ...args: unknown[]) { + for (const handler of listeners.get(event) ?? []) { + handler(...args) + } + } + } +} + +export type SendCommandCall = [string, Record?, string?] + +export function connect(endpoint: string): Promise { + return new Promise((resolve) => { + const ws = new WebSocket(endpoint) + ws.on('open', () => resolve(ws)) + }) +} + +export function sendAndReceive( + ws: WebSocket, + msg: Record +): Promise> { + return new Promise((resolve) => { + ws.once('message', (data) => resolve(JSON.parse(data.toString()))) + ws.send(JSON.stringify(msg)) + }) +} + +export function getSendCommandCalls(mock: MockWebContents): SendCommandCall[] { + return mock.webContents.debugger.sendCommand.mock.calls as unknown as SendCommandCall[] +} + +export function getSendCommandMethods(mock: MockWebContents): string[] { + return getSendCommandCalls(mock).map((call) => call[0]) +} diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 9f345a2e6ee..60248d21b15 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -1,63 +1,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import WebSocket from 'ws' import { CdpWsProxy } from './cdp-ws-proxy' +import { + connect, + createMockWebContents, + getSendCommandCalls, + getSendCommandMethods, + sendAndReceive, + type MockWebContents +} from './cdp-ws-proxy-test-harness' vi.mock('electron', () => ({ webContents: { fromId: vi.fn() } })) -type DebuggerListener = (...args: unknown[]) => void - -function createMockWebContents() { - const listeners = new Map() - let debuggerAttached = false - let destroyed = false - - const debuggerObj = { - isAttached: vi.fn(() => debuggerAttached), - attach: vi.fn(() => { - debuggerAttached = true - }), - detach: vi.fn(() => { - debuggerAttached = false - }), - sendCommand: vi.fn(async () => ({})), - on: vi.fn((event: string, handler: DebuggerListener) => { - const arr = listeners.get(event) ?? [] - arr.push(handler) - listeners.set(event, arr) - }), - removeListener: vi.fn((event: string, handler: DebuggerListener) => { - const arr = listeners.get(event) ?? [] - listeners.set( - event, - arr.filter((h) => h !== handler) - ) - }) - } - - return { - webContents: { - debugger: debuggerObj, - isDestroyed: () => destroyed, - focus: vi.fn(), - getTitle: vi.fn(() => 'Example'), - getURL: vi.fn(() => 'https://example.com') - }, - listeners, - destroy() { - destroyed = true - }, - emit(event: string, ...args: unknown[]) { - for (const handler of listeners.get(event) ?? []) { - handler(...args) - } - } - } -} - describe('CdpWsProxy', () => { - let mock: ReturnType + let mock: MockWebContents let proxy: CdpWsProxy let endpoint: string @@ -72,22 +30,14 @@ describe('CdpWsProxy', () => { await proxy.stop() }) - function connect(): Promise { - return new Promise((resolve) => { - const ws = new WebSocket(endpoint) - ws.on('open', () => resolve(ws)) - }) + function expectPdfStreamHandle(response: Record): string { + const result = response.result as Record + expect(result.data).toBe('') + expect(result.stream).toEqual(expect.stringMatching(/^orca-pdf-[\da-f-]{36}-\d+$/)) + return result.stream as string } - function sendAndReceive( - ws: WebSocket, - msg: Record - ): Promise> { - return new Promise((resolve) => { - ws.once('message', (data) => resolve(JSON.parse(data.toString()))) - ws.send(JSON.stringify(msg)) - }) - } + const defaultPdfMarginInches = 1 / 2.54 it('starts on a random port and returns ws:// URL', () => { expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/) @@ -111,7 +61,7 @@ describe('CdpWsProxy', () => { it('correlates CDP request/response IDs', async () => { mock.webContents.debugger.sendCommand.mockResolvedValueOnce({ tree: 'nodes' }) - const ws = connect() + const ws = connect(endpoint) const client = await ws const response = await sendAndReceive(client, { id: 42, @@ -127,7 +77,7 @@ describe('CdpWsProxy', () => { it('returns error response when sendCommand fails', async () => { mock.webContents.debugger.sendCommand.mockRejectedValueOnce(new Error('Node not found')) - const client = await connect() + const client = await connect(endpoint) const response = await sendAndReceive(client, { id: 7, method: 'DOM.describeNode', @@ -140,7 +90,7 @@ describe('CdpWsProxy', () => { }) it('returns an error instead of crashing when a command arrives after tab destruction', async () => { - const client = await connect() + const client = await connect(endpoint) mock.destroy() const response = await sendAndReceive(client, { @@ -177,7 +127,7 @@ describe('CdpWsProxy', () => { }) .mockResolvedValueOnce({ result: 'fast' }) - const client = await connect() + const client = await connect(endpoint) const responses: Record[] = [] client.on('message', (data) => { @@ -212,11 +162,11 @@ describe('CdpWsProxy', () => { ) .mockResolvedValueOnce({ result: 'new-client' }) - const firstClient = await connect() + const firstClient = await connect(endpoint) firstClient.send(JSON.stringify({ id: 1, method: 'DOM.enable', params: {} })) await new Promise((resolve) => setTimeout(resolve, 10)) - const secondClient = await connect() + const secondClient = await connect(endpoint) const responses: Record[] = [] secondClient.on('message', (data) => { responses.push(JSON.parse(data.toString())) @@ -238,7 +188,7 @@ describe('CdpWsProxy', () => { it('forwards sessionId to sendCommand for OOPIF support', async () => { mock.webContents.debugger.sendCommand.mockResolvedValueOnce({}) - const client = await connect() + const client = await connect(endpoint) await sendAndReceive(client, { id: 1, method: 'DOM.enable', @@ -257,7 +207,7 @@ describe('CdpWsProxy', () => { // ── Event forwarding ── it('forwards CDP events from debugger to client', async () => { - const client = await connect() + const client = await connect(endpoint) const eventPromise = new Promise>((resolve) => { client.on('message', (data) => resolve(JSON.parse(data.toString()))) @@ -272,7 +222,7 @@ describe('CdpWsProxy', () => { }) it('forwards sessionId in events when present', async () => { - const client = await connect() + const client = await connect(endpoint) const eventPromise = new Promise>((resolve) => { client.on('message', (data) => resolve(JSON.parse(data.toString()))) @@ -286,7 +236,7 @@ describe('CdpWsProxy', () => { }) it('does not focus the guest for Runtime.evaluate polling commands', async () => { - const client = await connect() + const client = await connect(endpoint) await sendAndReceive(client, { id: 9, @@ -299,7 +249,7 @@ describe('CdpWsProxy', () => { }) it('still focuses the guest for Input.insertText', async () => { - const client = await connect() + const client = await connect(endpoint) await sendAndReceive(client, { id: 10, @@ -308,6 +258,376 @@ describe('CdpWsProxy', () => { }) expect(mock.webContents.focus).toHaveBeenCalledTimes(1) + expect(getSendCommandMethods(mock)).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'Input.insertText' + ]) + client.close() + }) + + it('primes lifecycle events for Page.navigate', async () => { + const client = await connect(endpoint) + + const response = await sendAndReceive(client, { + id: 11, + method: 'Page.navigate', + params: { url: 'https://example.com/next' } + }) + + expect(response.id).toBe(11) + expect(response.result).toEqual({}) + expect(getSendCommandMethods(mock)).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'Network.enable', + 'Page.enable', + 'Page.setLifecycleEventsEnabled', + 'Page.navigate' + ]) + client.close() + }) + + it('primes lifecycle events for Page.reload and preserves response id', async () => { + const client = await connect(endpoint) + + const response = await sendAndReceive(client, { + id: 12, + method: 'Page.reload' + }) + + expect(response.id).toBe(12) + expect(response.result).toEqual({}) + expect(getSendCommandMethods(mock)).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'Network.enable', + 'Page.enable', + 'Page.setLifecycleEventsEnabled' + ]) + expect(mock.webContents.reload).toHaveBeenCalledTimes(1) + expect(getSendCommandMethods(mock)).not.toContain('Page.reload') + client.close() + }) + + it('preserves explicit Page.navigate session during lifecycle priming', async () => { + const client = await connect(endpoint) + + await sendAndReceive(client, { + id: 14, + method: 'Page.navigate', + params: { url: 'https://example.com/frame' }, + sessionId: 'iframe-session-123' + }) + + expect(getSendCommandCalls(mock).slice(2)).toEqual([ + ['Network.enable', {}, 'iframe-session-123'], + ['Page.enable', {}, 'iframe-session-123'], + ['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'], + ['Page.navigate', { url: 'https://example.com/frame' }, 'iframe-session-123'] + ]) + client.close() + }) + + it('forwards explicit Page.reload session after lifecycle priming', async () => { + const client = await connect(endpoint) + + await sendAndReceive(client, { + id: 15, + method: 'Page.reload', + params: { ignoreCache: true }, + sessionId: 'iframe-session-123' + }) + + expect(getSendCommandCalls(mock).slice(2)).toEqual([ + ['Network.enable', {}, 'iframe-session-123'], + ['Page.enable', {}, 'iframe-session-123'], + ['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'], + ['Page.reload', { ignoreCache: true }, 'iframe-session-123'] + ]) + expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled() + expect(mock.webContents.reload).not.toHaveBeenCalled() + client.close() + }) + + it('rejects root Page.reload params that direct webContents reload cannot honor', async () => { + const client = await connect(endpoint) + + const response = await sendAndReceive(client, { + id: 16, + method: 'Page.reload', + params: { loaderId: 'stale-loader' } + }) + + expect(response).toEqual({ + id: 16, + error: { + code: -32000, + message: 'Page.reload parameter "loaderId" is not supported for Orca tab reloads' + } + }) + expect(mock.webContents.reload).not.toHaveBeenCalled() + expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled() + expect(getSendCommandMethods(mock)).not.toContain('Network.enable') + client.close() + }) + + it('still reloads when lifecycle priming stalls', async () => { + const client = await connect(endpoint) + mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => { + if (method === 'Network.enable') { + return new Promise(() => {}) + } + return Promise.resolve({}) + }) + + const responsePromise = sendAndReceive(client, { + id: 17, + method: 'Page.reload' + }) + + await expect(responsePromise).resolves.toEqual({ id: 17, result: {} }) + expect(mock.webContents.reload).toHaveBeenCalledTimes(1) + client.close() + }) + + it('does not reload after the requesting client disconnects during priming', async () => { + const client = await connect(endpoint) + mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => { + if (method === 'Network.enable') { + return new Promise(() => {}) + } + return Promise.resolve({}) + }) + + client.send(JSON.stringify({ id: 18, method: 'Page.reload' })) + client.close() + + await new Promise((resolve) => setTimeout(resolve, 1_100)) + + expect(mock.webContents.reload).not.toHaveBeenCalled() + expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled() + }) + + it('forwards Runtime.evaluate without lifecycle priming', async () => { + const client = await connect(endpoint) + + const response = await sendAndReceive(client, { + id: 13, + method: 'Runtime.evaluate', + params: { expression: 'document.readyState' } + }) + + expect(response.id).toBe(13) + expect(response.result).toEqual({}) + expect(getSendCommandMethods(mock)).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'Runtime.evaluate' + ]) + client.close() + }) + + it('prints PDF data through native webContents printToPDF', async () => { + const client = await connect(endpoint) + + const response = await sendAndReceive(client, { + id: 19, + method: 'Page.printToPDF', + params: { + landscape: true, + printBackground: true, + paperWidth: 8.5, + paperHeight: 11, + marginTop: 0.25, + marginBottom: 0.5, + marginLeft: 0.75, + marginRight: 1, + pageRanges: '1-2', + preferCSSPageSize: true + } + }) + + expect(response).toEqual({ + id: 19, + result: { data: Buffer.from('%PDF-test').toString('base64') } + }) + expect(mock.webContents.printToPDF).toHaveBeenCalledWith({ + landscape: true, + printBackground: true, + pageSize: { width: 8.5, height: 11 }, + margins: { + marginType: 'custom', + top: 0.25, + bottom: 0.5, + left: 0.75, + right: 1 + }, + pageRanges: '1-2', + preferCSSPageSize: true + }) + expect(getSendCommandMethods(mock)).not.toContain('Page.printToPDF') + client.close() + }) + + it('keeps default PDF margins for omitted sides', async () => { + const client = await connect(endpoint) + + await sendAndReceive(client, { + id: 20, + method: 'Page.printToPDF', + params: { + marginTop: 0.25 + } + }) + + expect(mock.webContents.printToPDF).toHaveBeenCalledWith({ + margins: { + marginType: 'custom', + top: 0.25, + bottom: defaultPdfMarginInches, + left: defaultPdfMarginInches, + right: defaultPdfMarginInches + } + }) + client.close() + }) + + it('supports streamed Page.printToPDF results for Playwright page.pdf', async () => { + mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef')) + const client = await connect(endpoint) + + const printResponse = await sendAndReceive(client, { + id: 21, + method: 'Page.printToPDF', + params: { transferMode: 'ReturnAsStream' } + }) + const handle = expectPdfStreamHandle(printResponse) + + const firstRead = await sendAndReceive(client, { + id: 22, + method: 'IO.read', + params: { handle, size: 2 } + }) + const secondRead = await sendAndReceive(client, { + id: 23, + method: 'IO.read', + params: { handle } + }) + const closeResponse = await sendAndReceive(client, { + id: 24, + method: 'IO.close', + params: { handle } + }) + const readAfterClose = await sendAndReceive(client, { + id: 25, + method: 'IO.read', + params: { handle } + }) + + expect(printResponse.id).toBe(21) + expect(firstRead).toEqual({ + id: 22, + result: { base64Encoded: true, data: Buffer.from('ab').toString('base64'), eof: false } + }) + expect(secondRead).toEqual({ + id: 23, + result: { base64Encoded: true, data: Buffer.from('cdef').toString('base64'), eof: true } + }) + expect(closeResponse).toEqual({ id: 24, result: {} }) + expect(readAfterClose).toEqual({ + id: 25, + error: { code: -32000, message: 'Invalid stream handle' } + }) + client.close() + }) + + it('clears streamed PDF data when the client disconnects', async () => { + mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef')) + const client = await connect(endpoint) + + const printResponse = await sendAndReceive(client, { + id: 26, + method: 'Page.printToPDF', + params: { transferMode: 'ReturnAsStream' } + }) + const handle = expectPdfStreamHandle(printResponse) + + expect(printResponse.id).toBe(26) + client.close() + await new Promise((resolve) => setTimeout(resolve, 10)) + + const nextClient = await connect(endpoint) + const staleRead = await sendAndReceive(nextClient, { + id: 27, + method: 'IO.read', + params: { handle } + }) + + expect(staleRead).toEqual({ + id: 27, + error: { code: -32000, message: 'Invalid stream handle' } + }) + nextClient.close() + }) + + it('does not register a PDF stream when the client disconnects mid-print', async () => { + let resolvePrint: (buf: Buffer) => void = () => {} + mock.webContents.printToPDF.mockImplementationOnce( + () => + new Promise>((resolve) => { + resolvePrint = resolve + }) + ) + const store = (proxy as unknown as { pdfStreams: { create: (b: Buffer) => string } }).pdfStreams + const createSpy = vi.spyOn(store, 'create') + + const client = await connect(endpoint) + client.send( + JSON.stringify({ + id: 30, + method: 'Page.printToPDF', + params: { transferMode: 'ReturnAsStream' } + }) + ) + await new Promise((resolve) => setTimeout(resolve, 10)) + client.close() + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Print resolves only after the client is gone: no stream must be created. + resolvePrint(Buffer.from('%PDF-late')) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(createSpy).not.toHaveBeenCalled() + createSpy.mockRestore() + }) + + it('forwards non-PDF IO streams to the debugger', async () => { + mock.webContents.debugger.sendCommand + .mockResolvedValueOnce({ data: 'trace-data', eof: false }) + .mockResolvedValueOnce({}) + const client = await connect(endpoint) + + const readResponse = await sendAndReceive(client, { + id: 28, + method: 'IO.read', + params: { handle: 'trace-stream', size: 64 } + }) + const closeResponse = await sendAndReceive(client, { + id: 29, + method: 'IO.close', + params: { handle: 'trace-stream' } + }) + + expect(readResponse).toEqual({ id: 28, result: { data: 'trace-data', eof: false } }) + expect(closeResponse).toEqual({ id: 29, result: {} }) + expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.read', { + handle: 'trace-stream', + size: 64 + }) + expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.close', { + handle: 'trace-stream' + }) client.close() }) @@ -316,7 +636,7 @@ describe('CdpWsProxy', () => { // ── Cleanup ── it('detaches debugger and closes server on stop', async () => { - const client = await connect() + const client = await connect(endpoint) await proxy.stop() expect(mock.webContents.debugger.detach).toHaveBeenCalled() @@ -331,7 +651,7 @@ describe('CdpWsProxy', () => { }) it('detaches client websocket listeners after client close', async () => { - const client = await connect() + const client = await connect(endpoint) const serverClient = (proxy as unknown as { client: WebSocket | null }).client expect(serverClient).toBeTruthy() const offSpy = vi.spyOn(serverClient!, 'off') @@ -361,7 +681,7 @@ describe('CdpWsProxy', () => { }) ) - const client = await connect() + const client = await connect(endpoint) client.send(JSON.stringify({ id: 1, method: 'Page.enable', params: {} })) await new Promise((r) => setTimeout(r, 10)) diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 2531822b982..62e51342d26 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -3,10 +3,19 @@ import { WebSocketServer, WebSocket } from 'ws' import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http' import type { WebContents } from 'electron' import { captureScreenshot } from './cdp-screenshot' +import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf' import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' +const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000 + export class CdpWsProxy { + // Why: holds each session's last DOM.focus params to replay right before the next + // Input.insertText, countering the native webContents.focus() that would blur the target. + private pendingDomFocusBySession = new Map< + string | undefined, + Promise | undefined> + >() private httpServer: Server | null = null private wss: WebSocketServer | null = null private client: WebSocket | null = null @@ -18,6 +27,7 @@ export class CdpWsProxy { private attached = false // Why: agent-browser filters events by sessionId from Target.attachToTarget. private clientSessionId: string | undefined = undefined + private readonly pdfStreams = new CdpPdfStreamStore() constructor(private readonly webContents: WebContents) {} @@ -49,6 +59,7 @@ export class CdpWsProxy { const onClose = (): void => { detach() if (this.client === ws) { + this.pdfStreams.clear() this.client = null } } @@ -99,6 +110,9 @@ export class CdpWsProxy { this.detachClientListeners?.() this.detachClientListeners = null this.client = null + // Why: a pending focus belongs to the departing client; never replay it for the next one. + this.pendingDomFocusBySession.clear() + this.pdfStreams.clear() client?.close() } @@ -276,6 +290,12 @@ export class CdpWsProxy { ) return } + const effectiveSessionId = this.resolveDebuggerSessionId(msg.sessionId) + // Why: a stored focus is only valid for the immediately following Input.insertText; + // any other command may have moved DOM focus, so invalidate the replay in one place. + if (msg.method !== 'DOM.focus' && msg.method !== 'Input.insertText') { + this.pendingDomFocusBySession.delete(effectiveSessionId) + } if (msg.method === 'Page.bringToFront') { if (!this.webContents.isDestroyed()) { this.webContents.focus() @@ -283,11 +303,39 @@ export class CdpWsProxy { this.sendResult(clientId, {}, client) return } + if (msg.method === 'DOM.focus') { + this.forwardDomFocus(client, clientId, msg.params ?? {}, effectiveSessionId) + return + } // Why: Page.captureScreenshot via debugger.sendCommand hangs on Electron webview guests. if (msg.method === 'Page.captureScreenshot') { this.handleScreenshot(client, clientId, msg.params) return } + // Why: CDP Page.printToPDF is not available for Electron webview guests. + // Electron's native printToPDF path is the reliable equivalent. + if (msg.method === 'Page.printToPDF') { + void this.handlePrintToPdf(client, clientId, msg.params ?? {}) + return + } + if (msg.method === 'IO.read') { + const params = msg.params ?? {} + if (this.pdfStreams.ownsHandle(params)) { + this.handleStreamRead(client, clientId, params) + return + } + this.forwardCommand(client, clientId, msg.method, params, msg.sessionId) + return + } + if (msg.method === 'IO.close') { + const params = msg.params ?? {} + if (this.pdfStreams.ownsHandle(params)) { + this.handleStreamClose(client, clientId, params) + return + } + this.forwardCommand(client, clientId, msg.method, params, msg.sessionId) + return + } // Why: Input.insertText can still require native focus in Electron webviews. // Do not auto-focus generic Runtime.evaluate/callFunctionOn traffic: wait // polling and read-only JS probes use those methods heavily, and focusing on @@ -295,16 +343,44 @@ export class CdpWsProxy { // is running. if (msg.method === 'Input.insertText' && !this.webContents.isDestroyed()) { this.webContents.focus() + void this.forwardInsertText(client, clientId, msg.params ?? {}, effectiveSessionId) + return } // Why: agent-browser waits for network idle to detect navigation completion. // Electron webview CDP subscriptions silently lapse after cross-process swaps. + // Page.reload needs the same priming: forwarding it unprimed closed the tab (#7031). if (msg.method === 'Page.navigate' && !this.webContents.isDestroyed()) { - void this.navigateWithLifecycleEnsured(client, clientId, msg.params ?? {}) + void this.navigateWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId) + return + } + // Why: CDP Page.reload can destroy Electron webview targets during process swaps. + // Use the same direct webContents reload path as Orca's own browser.reload. + if (msg.method === 'Page.reload' && !this.webContents.isDestroyed()) { + void this.reloadWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId) return } this.forwardCommand(client, clientId, msg.method, msg.params ?? {}, msg.sessionId) } + private resolveDebuggerSessionId(msgSessionId?: string): string | undefined { + return msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined + } + + private isActiveClient(client: WebSocket): boolean { + return this.client === client && client.readyState === WebSocket.OPEN + } + + private sendDebuggerCommand( + method: string, + params: Record, + sessionId?: string + ): Promise { + const command = sessionId + ? this.webContents.debugger.sendCommand(method, params, sessionId) + : this.webContents.debugger.sendCommand(method, params) + return Promise.resolve(command) + } + private forwardCommand( client: WebSocket, clientId: number, @@ -316,10 +392,9 @@ export class CdpWsProxy { this.sendError(clientId, 'Browser tab is no longer available', client) return } - const sessionId = - msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined + const sessionId = this.resolveDebuggerSessionId(msgSessionId) try { - Promise.resolve(this.webContents.debugger.sendCommand(method, params, sessionId)) + this.sendDebuggerCommand(method, params, sessionId) .then((result) => { this.sendResult(clientId, result, client) }) @@ -331,21 +406,203 @@ export class CdpWsProxy { } } - private async navigateWithLifecycleEnsured( + private async navigateWithLifecycle( + client: WebSocket, + clientId: number, + params: Record, + msgSessionId?: string + ): Promise { + await this.primePageLifecycle(this.resolveDebuggerSessionId(msgSessionId)) + if (!this.isActiveClient(client)) { + return + } + this.forwardCommand(client, clientId, 'Page.navigate', params, msgSessionId) + } + + private async reloadWithLifecycle( + client: WebSocket, + clientId: number, + params: Record, + msgSessionId?: string + ): Promise { + const sessionId = this.resolveDebuggerSessionId(msgSessionId) + const unsupportedParam = sessionId ? null : this.getUnsupportedRootReloadParam(params) + if (unsupportedParam) { + this.sendError( + clientId, + `Page.reload parameter "${unsupportedParam}" is not supported for Orca tab reloads`, + client + ) + return + } + await this.primePageLifecycle(sessionId) + if (!this.isActiveClient(client)) { + return + } + if (sessionId) { + this.forwardCommand(client, clientId, 'Page.reload', params, msgSessionId) + return + } + if (this.webContents.isDestroyed()) { + this.sendError(clientId, 'Browser tab is no longer available', client) + return + } + try { + if (params.ignoreCache === true) { + this.webContents.reloadIgnoringCache() + } else { + this.webContents.reload() + } + this.sendResult(clientId, {}, client) + } catch (err) { + this.sendError(clientId, err instanceof Error ? err.message : String(err), client) + } + } + + private getUnsupportedRootReloadParam(params: Record): string | null { + return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null + } + + private async primePageLifecycle(sessionId?: string): Promise { + let timeout: ReturnType | null = null + const priming = (async (): Promise => { + // Why: without Network.enable, agent-browser never sees network idle → goto times out. + await this.sendDebuggerCommand('Network.enable', {}, sessionId) + await this.sendDebuggerCommand('Page.enable', {}, sessionId) + await this.sendDebuggerCommand('Page.setLifecycleEventsEnabled', { enabled: true }, sessionId) + })().catch(() => {}) + + try { + await Promise.race([ + priming, + new Promise((resolve) => { + timeout = setTimeout(resolve, LIFECYCLE_PRIMING_TIMEOUT_MS) + timeout.unref?.() + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } + } + + // Why: this must stay synchronous up to the `.set()` call so the pending-focus + // entry exists before the event loop can dispatch a pipelined Input.insertText + // message, closing the race where the replay would otherwise be silently skipped. + private forwardDomFocus( + client: WebSocket, + clientId: number, + params: Record, + effectiveSessionId?: string + ): void { + const focused = this.sendDomFocus(client, clientId, params, effectiveSessionId) + this.pendingDomFocusBySession.set(effectiveSessionId, focused) + } + + private async sendDomFocus( + client: WebSocket, + clientId: number, + params: Record, + effectiveSessionId?: string + ): Promise | undefined> { + if (this.webContents.isDestroyed()) { + this.sendError(clientId, 'Browser tab is no longer available', client) + return undefined + } + try { + const result = await this.sendDebuggerCommand('DOM.focus', params, effectiveSessionId) + this.sendResult(clientId, result, client) + return { ...params } + } catch (err) { + this.sendError(clientId, err instanceof Error ? err.message : String(err), client) + return undefined + } + } + + private async forwardInsertText( + client: WebSocket, + clientId: number, + params: Record, + effectiveSessionId?: string + ): Promise { + const pendingFocus = this.pendingDomFocusBySession.get(effectiveSessionId) + this.pendingDomFocusBySession.delete(effectiveSessionId) + const pendingFocusParams = pendingFocus ? await pendingFocus : undefined + // Why: the client can disconnect while DOM.focus is in flight; don't replay its + // focus or forward its insert into the live page once it is no longer active. + if (!this.isActiveClient(client)) { + return + } + if (pendingFocusParams) { + if (this.webContents.isDestroyed()) { + this.sendError(clientId, 'Browser tab is no longer available', client) + return + } + try { + await this.sendDebuggerCommand('DOM.focus', pendingFocusParams, effectiveSessionId) + } catch (err) { + this.sendError(clientId, err instanceof Error ? err.message : String(err), client) + return + } + // Why: the replay DOM.focus also awaited a round-trip; bail if the client vanished + // during it so its insert never lands in the live page. + if (!this.isActiveClient(client)) { + return + } + } + this.forwardCommand(client, clientId, 'Input.insertText', params, effectiveSessionId) + } + + private async handlePrintToPdf( client: WebSocket, clientId: number, params: Record ): Promise { - try { - const dbg = this.webContents.debugger - // Why: without Network.enable, agent-browser never sees network idle → goto times out. - await dbg.sendCommand('Network.enable', {}) - await dbg.sendCommand('Page.enable', {}) - await dbg.sendCommand('Page.setLifecycleEventsEnabled', { enabled: true }) - } catch { - /* best-effort */ + if (this.webContents.isDestroyed()) { + this.sendError(clientId, 'Browser tab is no longer available', client) + return } - this.forwardCommand(client, clientId, 'Page.navigate', params) + try { + const pdf = await this.webContents.printToPDF(buildPrintToPdfOptions(params)) + // Why: printToPDF can resolve after the client disconnected (or was + // replaced). Bail before registering a stream so its buffer isn't + // orphaned in pdfStreams past the disconnect's clear() until the TTL. + if (!this.isActiveClient(client)) { + return + } + const buffer = Buffer.isBuffer(pdf) ? pdf : Buffer.from(pdf) + if (params.transferMode === 'ReturnAsStream') { + const handle = this.pdfStreams.create(buffer) + this.sendResult(clientId, { data: '', stream: handle }, client) + return + } + this.sendResult(clientId, { data: buffer.toString('base64') }, client) + } catch (err) { + this.sendError(clientId, err instanceof Error ? err.message : String(err), client) + } + } + + private handleStreamRead( + client: WebSocket, + clientId: number, + params: Record + ): void { + const chunk = this.pdfStreams.read(params) + if (!chunk) { + this.sendError(clientId, 'Invalid stream handle', client) + return + } + this.sendResult(clientId, { base64Encoded: true, data: chunk.data, eof: chunk.eof }, client) + } + + private handleStreamClose( + client: WebSocket, + clientId: number, + params: Record + ): void { + this.pdfStreams.close(params) + this.sendResult(clientId, {}, client) } private handleScreenshot( diff --git a/src/main/cli/appimage-cli-wrapper.ts b/src/main/cli/appimage-cli-wrapper.ts index b8012581147..f66ecacfec2 100644 --- a/src/main/cli/appimage-cli-wrapper.ts +++ b/src/main/cli/appimage-cli-wrapper.ts @@ -32,6 +32,6 @@ ELECTRON_RUN_AS_NODE=1 exec "$APPIMAGE" -e ${quoteShell(APPIMAGE_CLI_SCRIPT)} -- ` } -function quoteShell(value: string): string { +export function quoteShell(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'` } diff --git a/src/main/cli/linux-bare-orca-dispatcher.test.ts b/src/main/cli/linux-bare-orca-dispatcher.test.ts new file mode 100644 index 00000000000..b2bb40e558b --- /dev/null +++ b/src/main/cli/linux-bare-orca-dispatcher.test.ts @@ -0,0 +1,130 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { isPackaged: true } +})) + +import { installLinuxBareOrcaDispatcher } from './linux-bare-orca-dispatcher' + +const created: string[] = [] + +async function makeFixture(): Promise<{ homePath: string; resourcesPath: string }> { + const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-')) + created.push(root) + const resourcesPath = join(root, 'resources') + // The bundled orca-ide launcher must exist for the dispatcher to be written. + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await writeFile(join(resourcesPath, 'bin', 'orca-ide'), '#!/usr/bin/env bash\n', 'utf8') + return { homePath: join(root, 'home'), resourcesPath } +} + +afterEach(async () => { + await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +describe('installLinuxBareOrcaDispatcher', () => { + it('writes an executable bare-orca dispatcher that execs the bundled orca-ide launcher', async () => { + const { homePath, resourcesPath } = await makeFixture() + + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath: null + }) + + const expectedTarget = join(resourcesPath, 'bin', 'orca-ide') + expect(result.state).toBe('installed') + expect(result.target).toBe(expectedTarget) + expect(result.dispatcherPath).toBe(join(homePath, '.local', 'bin', 'orca')) + + const content = await readFile(result.dispatcherPath, 'utf8') + expect(content).toContain('#!/usr/bin/env bash') + // Single-quoted so a resources path with shell metacharacters can't break out. + expect(content).toContain(`exec '${expectedTarget}' "$@"`) + + const mode = (await stat(result.dispatcherPath)).mode & 0o777 + expect(mode & 0o111).not.toBe(0) + }) + + it('is idempotent — a second install rewrites its own dispatcher without throwing', async () => { + const { homePath, resourcesPath } = await makeFixture() + + const first = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath: null + }) + const second = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath: null + }) + + expect(second).toEqual(first) + expect(second.state).toBe('installed') + }) + + it('quotes a resources path containing spaces so the exec line cannot be split', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-space-')) + created.push(root) + const resourcesPath = join(root, 'App Support', 'resources') + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await writeFile(join(resourcesPath, 'bin', 'orca-ide'), '#!/usr/bin/env bash\n', 'utf8') + + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath: join(root, 'home'), + appImagePath: null + }) + + const content = await readFile(result.dispatcherPath, 'utf8') + expect(content).toContain(`exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"`) + }) + + it('execs the stable AppImage (not the ephemeral mount) when running from an AppImage', async () => { + const { homePath, resourcesPath } = await makeFixture() + const appImagePath = join(homePath, 'Applications', 'Orca.AppImage') + + const result = await installLinuxBareOrcaDispatcher({ resourcesPath, homePath, appImagePath }) + + expect(result.state).toBe('installed') + expect(result.target).toBe(appImagePath) + const content = await readFile(result.dispatcherPath, 'utf8') + // The AppImage wrapper references the stable outer path, never resourcesPath. + expect(content).toContain(appImagePath) + expect(content).not.toContain(resourcesPath) + }) + + it('skips (does not clobber) a user-owned orca already at ~/.local/bin', async () => { + const { homePath, resourcesPath } = await makeFixture() + const dispatcherPath = join(homePath, '.local', 'bin', 'orca') + await mkdir(join(homePath, '.local', 'bin'), { recursive: true }) + await writeFile(dispatcherPath, '#!/bin/sh\necho my own orca\n', 'utf8') + + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath: null + }) + + expect(result.state).toBe('skipped-foreign') + expect(await readFile(dispatcherPath, 'utf8')).toBe('#!/bin/sh\necho my own orca\n') + }) + + it('skips when the bundled orca-ide launcher is missing from the build', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-nolauncher-')) + created.push(root) + + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath: join(root, 'resources'), + homePath: join(root, 'home'), + appImagePath: null + }) + + expect(result.state).toBe('skipped-launcher-missing') + expect(result.target).toBeNull() + }) +}) diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts new file mode 100644 index 00000000000..33c794b8150 --- /dev/null +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -0,0 +1,102 @@ +import { existsSync } from 'node:fs' +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { buildAppImageCliWrapper, quoteShell } from './appimage-cli-wrapper' +import { getBundledLauncherPath } from './cli-installer' + +// Why: marks a dispatcher this function wrote so repeat serve starts overwrite +// our own file idempotently but never clobber a user's own ~/.local/bin/orca. +const DISPATCHER_MARKER = '# orca-serve-bare-orca-dispatcher' + +export type LinuxBareOrcaDispatcherOptions = { + /** Packaged app resources root; the bundled `orca-ide` launcher lives under it. */ + resourcesPath: string + /** Test seam — defaults to the real home directory. */ + homePath?: string + /** Test seam — defaults to $APPIMAGE (set only when running from an AppImage). */ + appImagePath?: string | null +} + +export type LinuxBareOrcaDispatcherState = + | 'installed' + | 'skipped-foreign' + | 'skipped-launcher-missing' + +export type LinuxBareOrcaDispatcherResult = { + state: LinuxBareOrcaDispatcherState + dispatcherPath: string + /** What the dispatcher execs: the stable AppImage, or the bundled orca-ide. */ + target: string | null +} + +// Why: on Linux the CLI installs as `orca-ide`, not bare `orca`, to avoid +// shadowing GNOME Orca's /usr/bin/orca. But the Claude Team launcher typed into +// the initial managed terminal invokes the literal `orca claude-teams`, so a +// headless serve box needs a bare-`orca` dispatcher on the managed-terminal PATH +// (~/.local/bin, which patchPackagedProcessPath puts ahead of /usr/bin). It is a +// plain file, not a managed symlink, so CliInstaller.removeLegacyLinuxCommandIfManaged +// never reclaims it. +export async function installLinuxBareOrcaDispatcher( + options: LinuxBareOrcaDispatcherOptions +): Promise { + const dispatcherPath = join(options.homePath ?? homedir(), '.local', 'bin', 'orca') + const appImagePath = options.appImagePath ?? process.env.APPIMAGE ?? null + + const resolved = resolveDispatcherScript(options.resourcesPath, appImagePath) + if (!resolved) { + return { state: 'skipped-launcher-missing', dispatcherPath, target: null } + } + + // Why: only (re)write a dispatcher we previously created; leave a user's own + // `orca` untouched rather than silently clobbering it on every serve start. + if (existsSync(dispatcherPath) && !(await isOwnedDispatcher(dispatcherPath))) { + return { state: 'skipped-foreign', dispatcherPath, target: resolved.target } + } + + await mkdir(dirname(dispatcherPath), { recursive: true }) + await writeFile(dispatcherPath, resolved.script, 'utf8') + await chmod(dispatcherPath, 0o755) + return { state: 'installed', dispatcherPath, target: resolved.target } +} + +function resolveDispatcherScript( + resourcesPath: string, + appImagePath: string | null +): { script: string; target: string } | null { + if (appImagePath) { + // Why: an AppImage mounts resources under an ephemeral FUSE path per launch, + // so the dispatcher must exec the stable outer AppImage — reuse the same + // wrapper CliInstaller installs for the AppImage command. + return { script: withMarker(buildAppImageCliWrapper(appImagePath)), target: appImagePath } + } + + const launcher = getBundledLauncherPath('linux', resourcesPath) + // Why: getBundledLauncherPath only joins the path; guard existence so we never + // write a dispatcher pointing at a missing launcher (which would fail at exec + // time with a confusing error instead of the command-not-found we fix). + if (!launcher || !existsSync(launcher)) { + return null + } + return { + script: `#!/usr/bin/env bash\n${DISPATCHER_MARKER}\nexec ${quoteShell(launcher)} "$@"\n`, + target: launcher + } +} + +function withMarker(script: string): string { + const firstNewline = script.indexOf('\n') + if (firstNewline === -1) { + return `${script}\n${DISPATCHER_MARKER}\n` + } + // Keep the shebang on line 1; insert the marker immediately after it. + return `${script.slice(0, firstNewline + 1)}${DISPATCHER_MARKER}\n${script.slice(firstNewline + 1)}` +} + +async function isOwnedDispatcher(dispatcherPath: string): Promise { + try { + return (await readFile(dispatcherPath, 'utf8')).includes(DISPATCHER_MARKER) + } catch { + return false + } +} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index e32415aa9a3..af7756e45e7 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -6,6 +6,7 @@ import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync import { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonServer } from './daemon-server' import { getHistorySessionDirName } from './history-paths' +import type { HistoryReader } from './history-reader' import type { SubprocessHandle } from './session' import type * as DaemonHealthModule from './daemon-health' import { getDaemonSocketPath } from './daemon-spawner' @@ -1239,6 +1240,159 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false) }) + it('skips the cold-restore replay when the daemon session is still alive', async () => { + const sessionId = 'warm-reattach-skip-replay' + const first = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + await first.spawn({ cols: 80, rows: 24, cwd: '/home/user', sessionId }) + // Why disconnectOnly: the production app-quit path leaves meta.endedAt + // null so the session stays crash-recoverable — the state every app + // relaunch with a live daemon sees. + await first.disconnectOnly() + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const reader = (historyAdapter as unknown as { historyReader: HistoryReader }).historyReader + const detectSpy = vi.spyOn(reader, 'detectColdRestore') + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.isReattach).toBe(true) + expect(result.coldRestore).toBeUndefined() + expect(detectSpy).not.toHaveBeenCalled() + // The unmanaged-reattach re-anchor must survive the skipped detect. + const internals = historyAdapter as unknown as { + sessionsNeedingFullCheckpoint: Set + lastFullCheckpointAt: Map + } + expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true) + expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false) + }) + + it('does not probe session aliveness when there is no restorable history', async () => { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const client = ( + historyAdapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const requestSpy = vi.spyOn(client, 'request') + + await historyAdapter.spawn({ cols: 80, rows: 24, sessionId: 'fresh-no-history' }) + + expect(requestSpy.mock.calls.map((call) => call[0])).not.toContain('getSize') + }) + + it('recovers cold restore when the probed session dies before createOrAttach', async () => { + const sessionId = 'probe-race-cold-restore' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/raced', + cols: 100, + rows: 30, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'raced output\r\n') + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const client = ( + historyAdapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const originalRequest = client.request.bind(client) + // Why: simulates the probe→createOrAttach race — the probe sees the + // session alive, but it is gone by the time createOrAttach runs. The + // meta rewrite mimics the dying session's exit event beating the + // createOrAttach reply and writing endedAt via closeSession; the + // fallback detect must still restore instead of falling through to + // openSession (which would delete the checkpoint). + vi.spyOn(client, 'request').mockImplementation(async (type: string, payload?: unknown) => { + if (type === 'getSize') { + return { size: { cols: 100, rows: 30 } } + } + const response = await originalRequest(type, payload) + if (type === 'createOrAttach') { + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/raced', + cols: 100, + rows: 30, + startedAt: '2026-04-15T10:00:00Z', + endedAt: '2026-04-15T10:05:00Z', + exitCode: 0 + }) + ) + } + return response + }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.coldRestore).toBeDefined() + expect(result.coldRestore!.scrollback).toContain('raced output') + // Documented race delta: the fresh shell spawns with the renderer's + // requested params, not the recovered ones. + expect(lastSpawnOpts).toMatchObject({ sessionId, cols: 80, rows: 24 }) + // The recovery data must survive — openSession would have deleted it. + expect(existsSync(join(sessionDir, 'scrollback.bin'))).toBe(true) + const internals = historyAdapter as unknown as { + sessionsNeedingFullCheckpoint: Set + lastFullCheckpointAt: Map + } + expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true) + expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false) + }) + + it('falls back to the full cold-restore detect when the aliveness probe fails', async () => { + const sessionId = 'probe-error-cold-restore' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/probeless', + cols: 132, + rows: 43, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'probeless output\r\n') + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const client = ( + historyAdapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const originalRequest = client.request.bind(client) + // Why: an old daemon rejects the unknown getSize method; the spawn must + // behave exactly like the unprobed path. + vi.spyOn(client, 'request').mockImplementation((type: string, payload?: unknown) => { + if (type === 'getSize') { + return Promise.reject(new Error('Unknown request type')) + } + return originalRequest(type, payload) + }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.coldRestore).toBeDefined() + expect(result.coldRestore!.scrollback).toContain('probeless output') + expect(lastSpawnOpts).toMatchObject({ + sessionId, + cwd: '/projects/probeless', + cols: 132, + rows: 43 + }) + }) + it('returns same cold restore on StrictMode double-mount (sticky cache)', async () => { const sessionId = 'sticky-cache-test' const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index f4d717f1999..56a775a8c99 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -6,7 +6,7 @@ import { existsSync } from 'node:fs' import { DaemonClient } from './client' import { getMacDaemonSystemResolverHealth } from './daemon-health' import { HistoryManager } from './history-manager' -import { HistoryReader } from './history-reader' +import { HistoryReader, type ColdRestoreInfo } from './history-reader' import { mintPtySessionId, parsePtySessionId } from './pty-session-id' import { supportsPtyStartupBarrier } from './shell-ready' import { CODEX_SHELL_READY_TIMEOUT_MS } from './session' @@ -19,7 +19,12 @@ import { type SessionInfo, type TakePendingOutputResult } from './types' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' import { isShellProcess } from '../../shared/agent-detection' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' @@ -141,16 +146,30 @@ export class DaemonPtyAdapter implements IPtyProvider { await this.replaceUnhealthyMacResolverDaemonBeforeNewPty() } + await this.ensureConnected() + // Why: detect crash-recovery history before spawning a replacement PTY so // the revived shell inherits the recovered cwd and dimensions instead of // whatever the current renderer happened to request on mount. - const restoreInfo = this.historyReader?.detectColdRestore(sessionId) ?? null + // Why probe aliveness first: detectColdRestore synchronously replays the + // full checkpoint + log (up to ~5MB) through a scratch emulator on the + // main process, but a live daemon session ignores spawn params and its + // own snapshot supersedes disk — the replay result would be discarded. + // getSize is a read-only probe; on error/unsupported it degrades to the + // full detect. + let restoreInfo: ColdRestoreInfo | null = null + let restoreSkippedForLiveSession = false + if (this.historyReader?.hasRestorableHistory(sessionId)) { + if ((await this.getAppliedSize(sessionId)) !== null) { + restoreSkippedForLiveSession = true + } else { + restoreInfo = this.historyReader.detectColdRestore(sessionId) + } + } const effectiveCwd = restoreInfo?.cwd ?? opts.cwd const effectiveCols = restoreInfo?.cols ?? opts.cols const effectiveRows = restoreInfo?.rows ?? opts.rows - await this.ensureConnected() - const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false const isCodexStartupCommand = recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex' @@ -208,6 +227,19 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + // Why: the probe→createOrAttach gap is racy — the session can exit (or + // enter termination) in between, so the daemon spawned a fresh shell. + // Detect now so scrollback restore matches the unprobed path; only the + // new shell's cwd/dims came from the renderer request in this rare case. + // Why ignoreCleanEnd: the raced session's exit event (stream socket) can + // beat the createOrAttach reply and write endedAt via closeSession; that + // must not null the restore here, or the openSession branch below would + // delete the checkpoint instead of restoring it. + if (result.isNew && restoreSkippedForLiveSession) { + restoreInfo = + this.historyReader?.detectColdRestore(sessionId, { ignoreCleanEnd: true }) ?? null + } + const wasAlreadyManaged = this.activeSessionIds.has(sessionId) this.activeSessionIds.add(sessionId) @@ -490,7 +522,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { alive, killed } } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { await this.ensureConnected() const result = await this.client.request('listSessions', undefined) return result.sessions @@ -498,7 +530,8 @@ export class DaemonPtyAdapter implements IPtyProvider { .map((s) => ({ id: s.sessionId, cwd: s.cwd ?? '', - title: 'shell' + title: 'shell', + ...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {}) })) } diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 2212635d1fa..1f80f770cd9 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,5 +1,10 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter @@ -124,7 +129,7 @@ export class DaemonPtyRouter implements IPtyProvider { await this.current.revive(state) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { // Why: runtime exact-stop/liveness flows must fail closed if any adapter // cannot provide a trustworthy process list. const results = await Promise.all(this.allAdapters().map((adapter) => adapter.listProcesses())) diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index acf95ffcdfe..a7b8c68fa19 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -1,5 +1,10 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' type ManagedPtyProvider = IPtyProvider & { disconnectOnly?: () => Promise @@ -135,7 +140,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { await this.fallback.revive(state) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { const results = await Promise.all( this.allProviders().map((provider) => provider.listProcesses()) ) diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index 2914ed21aa7..f6661b50044 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -28,12 +28,28 @@ export class HistoryReader { this.basePath = basePath } - detectColdRestore(sessionId: string): ColdRestoreInfo | null { + // Why: spawn needs a cheap "could this cold-restore?" predicate before + // deciding to pay detectColdRestore's full checkpoint+log replay. Reads only + // the small meta.json, using the same unclean-shutdown test detectColdRestore + // starts with. + hasRestorableHistory(sessionId: string): boolean { + const meta = this.readMeta(sessionId) + return meta !== null && meta.endedAt === null + } + + detectColdRestore( + sessionId: string, + opts?: { ignoreCleanEnd?: boolean } + ): ColdRestoreInfo | null { const meta = this.readMeta(sessionId) if (!meta) { return null } - if (meta.endedAt !== null) { + // Why ignoreCleanEnd: in the spawn probe race, the dying session's exit + // event can write endedAt between the aliveness probe and the post-spawn + // fallback detect. The caller established restore eligibility before the + // probe, so the just-written clean end must not downgrade the restore. + if (meta.endedAt !== null && !opts?.ignoreCleanEnd) { return null } diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 551b8ad276c..b4d3433a5f8 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -56,6 +56,7 @@ export type SessionOptions = { sessionId: string cols: number rows: number + terminalHandle?: string subprocess: SubprocessHandle shellReadySupported: boolean shellReadyTimeoutMs?: number @@ -76,6 +77,7 @@ type AttachedClient = { export class Session { readonly sessionId: string + readonly terminalHandle: string | null private _state: SessionState = 'running' private _shellState: ShellReadyState private _exitCode: number | null = null @@ -97,6 +99,7 @@ export class Session { constructor(opts: SessionOptions) { this.sessionId = opts.sessionId + this.terminalHandle = opts.terminalHandle ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit const size = normalizePtySize(opts.cols, opts.rows) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 4731322e526..41e1f96b1bb 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -136,6 +136,7 @@ export class TerminalHost { sessionId: opts.sessionId, cols: size.cols, rows: size.rows, + terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, subprocess, shellReadySupported: opts.shellReadySupported ?? false, // Why: reap the dead session (dispose emulator + drop from the map) the @@ -301,6 +302,7 @@ export class TerminalHost { state: session.state, shellState: session.shellState, isAlive: true, + ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), pid: session.pid, cwd: session.getCwd(), cols: size?.cols ?? 0, diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 868afa81610..a38cc21a425 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -324,6 +324,7 @@ export type SessionInfo = { state: SessionState shellState: ShellReadyState isAlive: boolean + terminalHandle?: string pid: number | null cwd: string | null cols: number diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 44679c14ed3..69ce1ed46c5 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -237,6 +237,12 @@ bare }) describe('listWorktreeGraph', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileSyncMock.mockReset() + translateWslOutputPathsMock.mockClear() + }) + it('returns the worktree graph without sparse-checkout annotation probes', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: `worktree /repo @@ -272,6 +278,98 @@ branch refs/heads/feature/test }) }) + it('falls back when older Git rejects worktree list -z with usage exit code', async () => { + gitExecFileAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error('git usage error'), { + code: 129, + stderr: 'usage: git worktree list []\n' + }) + ) + .mockResolvedValueOnce({ + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-main-2 +HEAD def456 +branch refs/heads/main-2 +` + }) + + await expect(listWorktreeGraph('/repo')).resolves.toEqual([ + { + path: '/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/repo-main-2', + head: 'def456', + branch: 'refs/heads/main-2', + isBare: false, + isMainWorktree: false + } + ]) + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }], + [['worktree', 'list', '--porcelain'], { cwd: '/repo' }] + ]) + }) + + it('falls back on a localized (non-English) older-Git usage error via exit code 129', async () => { + // Git under a non-English locale translates the usage text, so stderr + // matching alone misses it; the 129 exit code must still trigger fallback. + gitExecFileAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error('git worktree list --porcelain -z'), { + code: 129, + stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list []\n' + }) + ) + .mockResolvedValueOnce({ + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + }) + + await expect(listWorktreeGraph('/repo')).resolves.toEqual([ + { + path: '/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }], + [['worktree', 'list', '--porcelain'], { cwd: '/repo' }] + ]) + }) + + it('does not retry without -z for non-usage Git failures', async () => { + // A fatal error (exit 128) is not an unsupported-flag signal, so the -z + // command must not be silently re-run without it. + gitExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('git worktree list --porcelain -z'), { + code: 128, + stderr: 'fatal: unable to read tree\n' + }) + ) + + await expect(listWorktreeGraph('/repo')).resolves.toEqual([]) + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }] + ]) + }) + it('returns an empty graph for paths Git reports as non-repositories', async () => { gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: not a git repository')) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index ed830d06d70..1dce3fff2ae 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -104,9 +104,13 @@ function isNotGitRepositoryError(error: unknown): boolean { } function isUnsupportedWorktreeListZError(error: unknown): boolean { - return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test( - getErrorText(error) - ) + // `-z` is this command's only flag older Git (<2.36) lacks, so its usage + // exit 129 signals the rejection in any locale; stderr match is a fallback. + if (getErrorCode(error) === '129') { + return true + } + + return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error)) } function isUnsupportedRevParsePathFormatError(error: unknown): boolean { diff --git a/src/main/index.ts b/src/main/index.ts index 8bc44bdc36d..73bcbb39f5f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -169,6 +169,8 @@ import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' import { KeybindingService } from './keybindings/keybinding-service' import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' +import { CliInstaller } from './cli/cli-installer' +import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher' let mainWindow: BrowserWindow | null = null /** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the @@ -1963,6 +1965,56 @@ app.whenReady().then(async () => { throw error }) installServeSignalHandlers() + // Why: the orca CLI command is normally installed by the renderer onboarding / + // Settings "Install CLI" flow via the cli:install IPC. Headless serve has no + // renderer, so the command is never created and an in-terminal `orca …` fails + // with command-not-found. Run the idempotent installer here for the platforms + // where it puts a resolvable command on the managed-terminal PATH: macOS (bare + // `orca` in /usr/local/bin or ~/.local/bin) and Linux (`orca-ide`; bare `orca` + // is added by the dispatcher below). Windows is excluded — there install() would + // only mutate the persistent user-registry PATH without helping the current + // serve's child terminals. Best-effort: a failure must not block serve start. + if (process.platform === 'darwin' || process.platform === 'linux') { + try { + // Why: serve is headless — never let a missing-write-permission fallback + // pop an osascript admin prompt (it would hang serve on a GUI host). Skip + // instead; the user-writable ~/.local/bin path needs no elevation anyway. + const cliStatus = await new CliInstaller({ + privilegedRunner: async () => { + throw new Error('serve CLI auto-install must not request administrator privileges') + } + }).install() + console.log( + `[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}` + ) + } catch (error) { + console.warn( + '[serve] orca CLI install skipped:', + error instanceof Error ? error.message : String(error) + ) + } + } + // Why: on Linux the CLI installs as `orca-ide`, NOT bare `orca` (above), but the + // Claude Team launcher typed into the initial managed terminal invokes bare `orca`. + // Drop a bare-`orca` dispatcher on ~/.local/bin (ahead of /usr/bin on the managed + // terminal PATH) so `orca claude-teams` resolves. Best-effort: a failure must not + // block serve startup. See installLinuxBareOrcaDispatcher for the full rationale. + if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { + try { + const dispatcher = await installLinuxBareOrcaDispatcher({ + resourcesPath: process.resourcesPath + }) + console.log( + `[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` + + `${dispatcher.target ? ` -> ${dispatcher.target}` : ''}` + ) + } catch (error) { + console.warn( + '[serve] bare orca dispatcher install skipped:', + error instanceof Error ? error.message : String(error) + ) + } + } await printServeReady(serveOptions) return } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index e3475692814..26c1bda9157 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3829,6 +3829,62 @@ describe('registerPtyHandlers', () => { const reported = await handlers.get('pty:getSize')!(null, { id }) expect(reported).toEqual({ cols: 100, rows: 30 }) }) + + it('fans out accepted desktop resizes to the runtime after provider resize', async () => { + const resize = vi.fn() + setupProviderWithAppliedSize({ applied: { cols: 120, rows: 30 }, resize }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + isResizeSuppressed: vi.fn(() => false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + onExternalPtyResize: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + + resizeListener()(mainWindowIpcEvent, { id, cols: 120, rows: 30 }) + + expect(resize).toHaveBeenCalledWith(id, 120, 30) + expect(runtime.onExternalPtyResize).toHaveBeenCalledWith(id, 120, 30) + expect(resize.mock.invocationCallOrder[0]).toBeLessThan( + runtime.onExternalPtyResize.mock.invocationCallOrder[0]! + ) + }) + + it('does not fan out rejected desktop resizes to the runtime', async () => { + setupProviderWithAppliedSize({ + applied: { cols: 80, rows: 24 }, + resize: () => { + throw new Error('resize rejected') + } + }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + isResizeSuppressed: vi.fn(() => false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + onExternalPtyResize: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + + resizeListener()(mainWindowIpcEvent, { id, cols: 120, rows: 30 }) + + expect(runtime.onExternalPtyResize).not.toHaveBeenCalled() + }) }) it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 419016f6f83..2e609b20558 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -2828,6 +2828,24 @@ describe('Store', () => { expect(store.getSettings().terminalUseSeparateLightTheme).toBe(false) }) + it('round-trips selected terminal theme names across reload', async () => { + const store = await createStore() + + store.updateSettings({ + terminalThemeDark: 'One Light', + terminalThemeLight: 'GitHub Light' + }) + store.flush() + + const persisted = readDataFile() as PersistedState + expect(persisted.settings.terminalThemeDark).toBe('One Light') + expect(persisted.settings.terminalThemeLight).toBe('GitHub Light') + + const reopened = await createStore() + expect(reopened.getSettings().terminalThemeDark).toBe('One Light') + expect(reopened.getSettings().terminalThemeLight).toBe('GitHub Light') + }) + // ── 5. addRepo and getRepo ────────────────────────────────────────── it('addRepo stores a repo retrievable by getRepo', async () => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 01ea3aeea57..7e8588a6b64 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -21,7 +21,7 @@ import { updateHistFileForFallback, logHistoryInjection } from '../terminal-history' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { ensureNodePtySpawnHelperExecutable, validateWorkingDirectory, @@ -67,6 +67,7 @@ let ptyCounter = 0 const ptyProcesses = new Map() const ptyShellName = new Map() const ptyAgentForegroundContextPaths = new Map() +const ptyTerminalHandle = new Map() // Why: node-pty's onData/onExit register native NAPI ThreadSafeFunction // callbacks. If the PTY is killed without disposing these listeners, the // stale callbacks survive into node::FreeEnvironment() where NAPI attempts @@ -187,6 +188,7 @@ function clearPtyState(id: string): void { ptyProcesses.delete(id) ptyShellName.delete(id) ptyAgentForegroundContextPaths.delete(id) + ptyTerminalHandle.delete(id) ptyLoadGeneration.delete(id) } @@ -680,6 +682,9 @@ export class LocalPtyProvider implements IPtyProvider { const proc = spawnResult.process ptyProcesses.set(id, proc) ptyShellName.set(id, getSpawnedShellName(shellPath)) + if (finalEnv.ORCA_TERMINAL_HANDLE) { + ptyTerminalHandle.set(id, finalEnv.ORCA_TERMINAL_HANDLE) + } ptyAgentForegroundContextPaths.set( id, getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId }) @@ -945,11 +950,12 @@ export class LocalPtyProvider implements IPtyProvider { /* re-spawning handles local revival */ } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ id, cwd: '', - title: proc.process || ptyShellName.get(id) || 'shell' + title: proc.process || ptyShellName.get(id) || 'shell', + ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}) })) } diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 7284f2353ba..398cc935142 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -1,5 +1,5 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env' @@ -254,9 +254,9 @@ export class SshPtyProvider implements IPtyProvider { await this.mux.request('pty.revive', { state }) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { const result = await this.mux.request('pty.listProcesses') - return (result as { id: string; cwd: string; title: string }[]).map((session) => ({ + return (result as PtyProcessInfo[]).map((session) => ({ ...session, id: this.toAppPtyId(session.id) })) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index a7516ec4073..ec9061c5130 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -99,6 +99,14 @@ export type PtySpawnResult = { } } +export type PtyProcessInfo = { + id: string + cwd: string + title: string + /** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */ + terminalHandle?: string +} + export type IPtyProvider = { spawn(opts: PtySpawnOptions): Promise attach(id: string): Promise @@ -128,7 +136,7 @@ export type IPtyProvider = { getForegroundProcess(id: string): Promise serialize(ids: string[]): Promise revive(state: string): Promise - listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> + listProcesses(): Promise getDefaultShell(): Promise getProfiles(): Promise<{ name: string; path: string }[]> onData(callback: (payload: { id: string; data: string }) => void): () => void diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 24ee44f7cec..6571177a928 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -1,14 +1,17 @@ /* eslint-disable max-lines -- Why: Claude rate-limit fallback tests share account/keychain/PTY mocks that would be noisier split apart. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' import { fetchViaPty } from './claude-pty' import { + deleteActiveClaudeKeychainCredentialsStrict, readActiveClaudeKeychainCredentials, readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials + readManagedClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials } from '../claude-accounts/keychain' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' @@ -46,9 +49,12 @@ vi.mock('./claude-pty', () => ({ })) vi.mock('../claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), readActiveClaudeKeychainCredentials: vi.fn(), readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn() + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(), + writeManagedClaudeKeychainCredentials: vi.fn() })) const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -71,6 +77,9 @@ describe('fetchClaudeRateLimits', () => { vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(null) vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue(null) + vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue() + vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockResolvedValue() + vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue() appGetPathMock.mockReturnValue('/tmp/orca-claude-fetcher-test') resolveProxyMock.mockResolvedValue('DIRECT') netFetchMock.mockResolvedValue( @@ -948,6 +957,239 @@ describe('fetchClaudeRateLimits', () => { expect(readFileMock).not.toHaveBeenCalled() }) + it('supplements inactive managed account OAuth usage with Fable from its usage panel', async () => { + setPlatform('linux') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const canonicalAuthPath = realpathSync(ownedAuthPath) + writeFileSync( + join(ownedAuthPath, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { + accessToken: 'inactive-token', + expiresAt: Date.now() + 60_000 + } + }), + 'utf-8' + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: null, + weekly: null, + fableWeekly: { + usedPercent: 42, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '2d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + await expect( + fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 }, + fableWeekly: { usedPercent: 42, resetDescription: '2d' } + }) + expect(fetchViaPty).toHaveBeenCalledWith({ + authPreparation: expect.objectContaining({ + configDir: canonicalAuthPath, + envPatch: { CLAUDE_CONFIG_DIR: canonicalAuthPath }, + provenance: 'managed:account-1:inactive-preview', + stripAuthEnv: true + }) + }) + }) + + it('stages macOS inactive account credentials in a scoped Keychain for Fable preview', async () => { + setPlatform('darwin') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + const credentialsJson = JSON.stringify({ + claudeAiOauth: { + accessToken: 'managed-keychain-token', + expiresAt: Date.now() + 60_000 + } + }) + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const canonicalAuthPath = realpathSync(ownedAuthPath) + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(credentialsJson) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: { + usedPercent: 12, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 34, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 58, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '3d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + const result = await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) + expect(writeActiveClaudeKeychainCredentials).toHaveBeenCalledWith( + credentialsJson, + canonicalAuthPath + ) + expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath) + }) + + it('stages refreshed macOS inactive account credentials before Fable preview', async () => { + setPlatform('darwin') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + const staleCredentialsJson = JSON.stringify({ + claudeAiOauth: { + accessToken: 'stale-access', + refreshToken: 'stale-refresh', + expiresAt: Date.now() - 60_000 + } + }) + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(staleCredentialsJson) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'fresh-access', + expires_in: 3600, + refresh_token: 'fresh-refresh' + }), + { status: 200 } + ) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }), + { + status: 200 + } + ) + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: { + usedPercent: 12, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 34, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 58, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '3d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + const result = await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + const stagedCredentialsJson = vi.mocked(writeActiveClaudeKeychainCredentials).mock.calls[0]?.[0] + expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) + expect(JSON.parse(stagedCredentialsJson ?? '{}')).toMatchObject({ + claudeAiOauth: { + accessToken: 'fresh-access', + refreshToken: 'fresh-refresh' + } + }) + expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( + 'account-1', + stagedCredentialsJson + ) + }) + + it('does not merge macOS inactive Fable preview when usage windows belong to another account', async () => { + setPlatform('darwin') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'managed-keychain-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: { + usedPercent: 91, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 3, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 58, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '3d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + const result = await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + expect(result.fableWeekly).toBeNull() + }) + it('refreshes and persists an expiring inactive account before fetching usage', async () => { setPlatform('linux') tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 5f283e58ec8..b546d21b83e 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -17,9 +17,11 @@ import { parseWslUncPath } from '../../shared/wsl-paths' import { fetchViaPty } from './claude-pty' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { + deleteActiveClaudeKeychainCredentialsStrict, readActiveClaudeKeychainCredentials, readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials + readManagedClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials } from '../claude-accounts/keychain' import { readClaudeManagedAuthFile, @@ -885,7 +887,7 @@ export type InactiveClaudeAccountInfo = { } type ManagedCredentialsLocation = - | { kind: 'keychain'; accountId: string } + | { kind: 'keychain'; accountId: string; managedAuthPath: string } | { kind: 'file'; managedAuthPath: string } // Why: resolves where an inactive account's credentials live without @@ -907,7 +909,7 @@ function resolveManagedCredentialsLocation( // macOS stores host managed credentials in the Keychain; everything else // (and WSL, handled above) stores them as a file under the managed dir. if (process.platform === 'darwin') { - return { kind: 'keychain', accountId: account.id } + return { kind: 'keychain', accountId: account.id, managedAuthPath } } return { kind: 'file', managedAuthPath } } @@ -966,12 +968,127 @@ function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccountInfo } } -export async function fetchManagedAccountUsage( +function getManagedUsagePanelAuthPreparation( + account: InactiveClaudeAccountInfo, + location: ManagedCredentialsLocation +): ClaudeRuntimeAuthPreparation | null { + if (process.platform === 'win32') { + return null + } + if (account.managedAuthRuntime === 'wsl') { + if (!account.wslLinuxAuthPath || !account.wslDistro) { + return null + } + return { + configDir: location.managedAuthPath, + runtime: 'wsl', + wslDistro: account.wslDistro, + wslLinuxConfigDir: account.wslLinuxAuthPath, + envPatch: { CLAUDE_CONFIG_DIR: account.wslLinuxAuthPath }, + stripAuthEnv: true, + provenance: `managed:${account.id}:inactive-preview` + } + } + return { + configDir: location.managedAuthPath, + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { CLAUDE_CONFIG_DIR: location.managedAuthPath }, + stripAuthEnv: true, + provenance: `managed:${account.id}:inactive-preview` + } +} + +function windowsAgree(left: RateLimitWindow | null, right: RateLimitWindow | null): boolean { + return Boolean(left && right && Math.abs(left.usedPercent - right.usedPercent) <= 1) +} + +function canTrustManagedUsagePanelSupplement( + oauthLimits: ProviderRateLimits, + cliLimits: ProviderRateLimits, + options: { requireMatchingOAuthWindow: boolean } +): boolean { + if (!options.requireMatchingOAuthWindow) { + return true + } + const sharedWindowMatches = [ + oauthLimits.session && cliLimits.session + ? windowsAgree(oauthLimits.session, cliLimits.session) + : null, + oauthLimits.weekly && cliLimits.weekly + ? windowsAgree(oauthLimits.weekly, cliLimits.weekly) + : null + ].filter((match): match is boolean => match !== null) + // Why: macOS inactive previews temporarily stage managed credentials in a + // scoped Keychain item. If an older Claude build ignores scoped Keychains, + // matching OAuth windows prevent active-account Fable data from leaking in. + return sharedWindowMatches.length > 0 && sharedWindowMatches.every(Boolean) +} + +async function withManagedPreviewKeychainCredentials( + location: ManagedCredentialsLocation, + credentialsJson: string, + fn: () => Promise +): Promise { + if (location.kind !== 'keychain') { + return fn() + } + await writeActiveClaudeKeychainCredentials(credentialsJson, location.managedAuthPath) + try { + return await fn() + } finally { + await deleteActiveClaudeKeychainCredentialsStrict(location.managedAuthPath).catch(() => {}) + } +} + +async function readStagedManagedPreviewCredentials( + location: ManagedCredentialsLocation +): Promise { + if (location.kind !== 'keychain') { + return null + } + try { + return await readActiveClaudeKeychainCredentialsStrict(location.managedAuthPath) + } catch { + return null + } +} + +async function fetchManagedUsagePanelSupplement(input: { account: InactiveClaudeAccountInfo + location: ManagedCredentialsLocation + credentialsJson: string + oauthLimits: ProviderRateLimits +}): Promise { + const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location) + if (!authPreparation) { + return null + } + return withManagedPreviewKeychainCredentials(input.location, input.credentialsJson, async () => { + const cliLimits = await fetchViaPty({ authPreparation }) + if ( + !canTrustManagedUsagePanelSupplement(input.oauthLimits, cliLimits, { + requireMatchingOAuthWindow: input.location.kind === 'keychain' + }) + ) { + return null + } + const refreshedCredentials = await readStagedManagedPreviewCredentials(input.location) + if (refreshedCredentials && refreshedCredentials !== input.credentialsJson) { + await writeManagedCredentialsJson(input.location, refreshedCredentials) + } + return cliLimits + }) +} + +export async function fetchManagedAccountUsage( + account: InactiveClaudeAccountInfo, + options: { allowUsagePanelSupplement?: boolean } = {} ): Promise { const location = resolveManagedCredentialsLocation(account) - const credentialsJson = location ? await readManagedCredentialsJson(location) : null - if (!credentialsJson) { + let credentialsJson = location ? await readManagedCredentialsJson(location) : null + if (!location || !credentialsJson) { return { provider: 'claude', session: null, @@ -988,7 +1105,7 @@ export async function fetchManagedAccountUsage( // single-use refresh tokens fresh so a later switch-in never materializes a // stale token. Persistence failure is non-fatal: we still try the fetch. let token = parseOAuthCredentialsJson(credentialsJson, 'credentials-file').token - if (location && isOauthTokenExpiring(credentialsJson)) { + if (isOauthTokenExpiring(credentialsJson)) { const refreshed = await refreshClaudeOauthCredentials(credentialsJson) if (refreshed) { try { @@ -997,6 +1114,7 @@ export async function fetchManagedAccountUsage( // Keep going with the refreshed token in memory even if the write // failed; worst case the next poll refreshes again. } + credentialsJson = refreshed token = parseOAuthCredentialsJson(refreshed, 'credentials-file').token } } @@ -1013,7 +1131,32 @@ export async function fetchManagedAccountUsage( } // Why: PTY fallback is intentionally omitted for inactive accounts. The PTY - // path materializes credentials via ClaudeRuntimeAuthService, which would - // interfere with the active account's auth state. - return fetchViaOAuth(token) + // path is used only as a supplement after OAuth succeeds, and it points + // directly at the managed account's isolated config so selection is unchanged. + const oauthLimits = await fetchViaOAuth(token) + if ( + !canSupplementOAuthUsageFromCli({ + oauthLimits, + authPreparation: undefined, + allowUsagePanelSupplement: options.allowUsagePanelSupplement === true + }) + ) { + return oauthLimits + } + try { + const cliLimits = await fetchManagedUsagePanelSupplement({ + account, + location, + credentialsJson, + oauthLimits + }) + return mergeClaudeUsageWindows(oauthLimits, cliLimits) + } catch (err) { + warnClaudeUsageFetchFailure( + undefined, + parseOAuthCredentialsJson(credentialsJson, 'credentials-file'), + err + ) + return oauthLimits + } } diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index a613194868e..acea13231c7 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -258,6 +258,49 @@ describe('fetchViaPty', () => { }) }) + it('parses Claude current-week Fable usage as a distinct weekly window', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + 8% used + Resets 3:39am + + Current week (all models) + 33% used + Resets Jul 3 at 12:59pm + + Current week (Fable) + 62% used + Resets Jul 3 at 12:59pm + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 8, + resetDescription: '3:39am' + }, + weekly: { + usedPercent: 33, + resetDescription: 'Jul 3 at 12:59pm' + }, + fableWeekly: { + usedPercent: 62, + resetDescription: 'Jul 3 at 12:59pm' + }, + error: null + }) + }) + it('does not let an incomplete Fable section consume later usage sections', async () => { const term = makeMockTerm() spawnMock.mockReturnValue(term) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index f0363eba3f2..20919a391ca 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -22,6 +22,8 @@ const SESSION_RE = /current\s*session/i const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i const FABLE_WORD_RE = /\bfable\b/i const FABLE_LABEL_RE = /^\s*fable\s*$/i +const FABLE_WEEKLY_LABEL_RE = + /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)\s*(?:\([^)]*\bfable\b[^)]*\)|[-:]?\s*\bfable\b)/i const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i const ESC = String.fromCharCode(27) @@ -45,6 +47,12 @@ function matchesFableBoundary(line: string): boolean { return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) } +function matchesFableUsageLabel(line: string): boolean { + // Why: broad Fable-weekly copy should stop nearby scans, but only a real + // Fable usage heading should produce the distinct Fable meter. + return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) +} + function isSectionLabel(line: string): boolean { return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) } @@ -104,7 +112,7 @@ function parsePtyUsage(output: string): { const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) - const fableWeeklyPct = extractPercentAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) + const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) const session: RateLimitWindow | null = sessionPct !== null @@ -132,7 +140,7 @@ function parsePtyUsage(output: string): { usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), windowMinutes: 10080, resetsAt: null, - resetDescription: extractResetAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) + resetDescription: extractResetAfterLabel(lines, matchesFableUsageLabel) } : null diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index c5c389bd0e3..5af9abca04f 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -621,6 +621,19 @@ describe('RateLimitService', () => { ]) }) + it('allows usage-panel Fable supplements for inactive Claude account previews', async () => { + const service = new RateLimitService() + const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + service.setInactiveClaudeAccountsResolver(() => [account]) + vi.mocked(fetchManagedAccountUsage).mockResolvedValueOnce(okProvider('claude', 33, Date.now())) + + await service.fetchInactiveClaudeAccountsOnOpen() + + expect(fetchManagedAccountUsage).toHaveBeenCalledWith(account, { + allowUsagePanelSupplement: true + }) + }) + it('does not start overlapping inactive Codex preview fetches', async () => { const service = new RateLimitService() const accountFetch = deferred() diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index af7431bcad6..5225485ee27 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -382,7 +382,9 @@ export class RateLimitService { continue } try { - const fresh = await fetchManagedAccountUsage(account) + const fresh = await fetchManagedAccountUsage(account, { + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement() + }) if ( fetchGeneration !== this.inactiveClaudeAccountsGeneration || !this.isCurrentInactiveClaudeAccount(account.id) diff --git a/src/main/runtime/orca-runtime-browser.test.ts b/src/main/runtime/orca-runtime-browser.test.ts index 7e060928d66..b3edc6cdbfd 100644 --- a/src/main/runtime/orca-runtime-browser.test.ts +++ b/src/main/runtime/orca-runtime-browser.test.ts @@ -11,14 +11,42 @@ const { webContentsFromIdMock, startBrowserScreencastMock, waitForTabRegistrationMock, - waitForWorktreeTabRegistrationMock + waitForWorktreeTabRegistrationMock, + browserSessionRegistryMock } = vi.hoisted(() => ({ ipcMainOnMock: vi.fn(), ipcMainRemoveListenerMock: vi.fn(), webContentsFromIdMock: vi.fn(), startBrowserScreencastMock: vi.fn(), waitForTabRegistrationMock: vi.fn(), - waitForWorktreeTabRegistrationMock: vi.fn() + waitForWorktreeTabRegistrationMock: vi.fn(), + browserSessionRegistryMock: { + profiles: new Map([ + [ + 'default', + { + id: 'default', + scope: 'default', + partition: 'persist:orca-browser', + label: 'Default', + source: null + } + ], + [ + 'profile-isolated', + { + id: 'profile-isolated', + scope: 'isolated', + partition: 'persist:orca-browser-session-profile-isolated', + label: 'Isolated', + source: null + } + ] + ]), + getDefaultProfile: vi.fn(), + getProfile: vi.fn(), + resolveKnownPartition: vi.fn() + } })) vi.mock('electron', () => ({ @@ -35,6 +63,10 @@ vi.mock('../ipc/browser', () => ({ waitForWorktreeTabRegistration: waitForWorktreeTabRegistrationMock })) +vi.mock('../browser/browser-session-registry', () => ({ + browserSessionRegistry: browserSessionRegistryMock +})) + function deferred() { let resolve!: (value: T) => void let reject!: (reason?: unknown) => void @@ -83,6 +115,23 @@ describe('RuntimeBrowserCommands browser screencast', () => { waitForTabRegistrationMock.mockResolvedValue(undefined) waitForWorktreeTabRegistrationMock.mockReset() waitForWorktreeTabRegistrationMock.mockResolvedValue(undefined) + browserSessionRegistryMock.getDefaultProfile.mockReset() + browserSessionRegistryMock.getDefaultProfile.mockImplementation(() => + browserSessionRegistryMock.profiles.get('default') + ) + browserSessionRegistryMock.getProfile.mockReset() + browserSessionRegistryMock.getProfile.mockImplementation( + (profileId: string) => browserSessionRegistryMock.profiles.get(profileId) ?? null + ) + browserSessionRegistryMock.resolveKnownPartition.mockReset() + browserSessionRegistryMock.resolveKnownPartition.mockImplementation( + (profileId: string | null | undefined) => { + if (!profileId) { + return 'persist:orca-browser' + } + return browserSessionRegistryMock.profiles.get(profileId)?.partition ?? null + } + ) }) it('waits for explicit worktree browser registration after requesting a hidden mount', async () => { @@ -191,14 +240,133 @@ describe('RuntimeBrowserCommands browser screencast', () => { ).resolves.toEqual({ browserPageId: 'page-new' }) expect(waitForWorktreeTabRegistrationMock).not.toHaveBeenCalled() + // Why: with no explicit profile, main must leave sessionProfileId/ + // sessionPartition undefined so the renderer applies the user's configured + // default-profile inheritance instead of being forced onto the shared + // default partition. expect(send).toHaveBeenCalledWith( 'browser:requestTabCreate', - expect.objectContaining({ url: 'about:blank', worktreeId: 'wt-1' }) + expect.objectContaining({ + url: 'about:blank', + worktreeId: 'wt-1', + sessionProfileId: undefined, + sessionPartition: undefined + }) ) expect(waitForTabRegistrationMock).toHaveBeenCalledWith('page-new') expect(bridge.setActiveTab).toHaveBeenCalledWith(101, 'wt-1') }) + it('sends the resolved isolated profile partition when creating a renderer tab', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const webContents = { send: vi.fn() } + const send = vi.fn((channel: string, data: { requestId: string }) => { + expect(channel).toBe('browser:requestTabCreate') + const handler = ipcMainOnMock.mock.calls.find( + ([eventName]) => eventName === 'browser:tabCreateReply' + )?.[1] as + | ((event: unknown, reply: { requestId: string; browserPageId?: string }) => void) + | undefined + handler?.({ sender: webContents } as never, { + requestId: data.requestId, + browserPageId: 'page-isolated' + }) + }) + webContents.send = send + const bridge = { + getRegisteredTabs: vi.fn(() => new Map([['page-isolated', 101]])), + getActivePageId: vi.fn(() => 'page-isolated'), + setActiveTab: vi.fn(), + tabList: vi.fn(() => ({ tabs: [] })) + } as unknown as AgentBrowserBridge + const commands = new RuntimeBrowserCommands( + createHost({ + getAgentBrowserBridge: () => bridge, + getAvailableAuthoritativeWindow: vi.fn(() => ({}) as never), + getAuthoritativeWindow: vi.fn(() => ({ webContents }) as never) + }) + ) + + await expect( + commands.browserTabCreate({ + worktree: 'id:wt-1', + url: 'https://example.com', + profileId: 'profile-isolated' + }) + ).resolves.toEqual({ browserPageId: 'page-isolated' }) + + expect(send).toHaveBeenCalledWith( + 'browser:requestTabCreate', + expect.objectContaining({ + sessionProfileId: 'profile-isolated', + sessionPartition: 'persist:orca-browser-session-profile-isolated' + }) + ) + }) + + it('rejects unknown explicit profile ids before requesting a renderer tab', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const send = vi.fn() + const commands = new RuntimeBrowserCommands( + createHost({ + getAvailableAuthoritativeWindow: vi.fn(() => ({}) as never), + getAuthoritativeWindow: vi.fn(() => ({ webContents: { send } }) as never) + }) + ) + + await expect( + commands.browserTabCreate({ + worktree: 'id:wt-1', + url: 'https://example.com', + profileId: 'missing-profile' + }) + ).rejects.toThrow(/Browser profile missing-profile was not found/) + + expect(send).not.toHaveBeenCalled() + }) + + it('sends the resolved partition when switching a renderer tab profile', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const webContents = { send: vi.fn() } + const send = vi.fn((channel: string, data: { requestId: string }) => { + expect(channel).toBe('browser:requestTabSetProfile') + const handler = ipcMainOnMock.mock.calls.find( + ([eventName]) => eventName === 'browser:tabSetProfileReply' + )?.[1] as ((event: unknown, reply: { requestId: string; error?: string }) => void) | undefined + handler?.({ sender: webContents } as never, { requestId: data.requestId }) + }) + webContents.send = send + const bridge = { + getRegisteredTabs: vi.fn(() => new Map([['page-1', 101]])), + getActivePageId: vi.fn(() => 'page-1') + } as unknown as AgentBrowserBridge + const commands = new RuntimeBrowserCommands( + createHost({ + getAgentBrowserBridge: () => bridge, + getAvailableAuthoritativeWindow: vi.fn(() => ({}) as never), + getAuthoritativeWindow: vi.fn(() => ({ webContents }) as never) + }) + ) + + await expect( + commands.browserTabSetProfile({ worktree: 'id:wt-1', profileId: 'profile-isolated' }) + ).resolves.toEqual({ + browserPageId: 'page-1', + profileId: 'profile-isolated', + profileLabel: 'Isolated' + }) + + expect(send).toHaveBeenCalledWith( + 'browser:requestTabSetProfile', + expect.objectContaining({ + browserPageId: 'page-1', + profileId: 'profile-isolated', + sessionPartition: 'persist:orca-browser-session-profile-isolated' + }) + ) + expect(waitForTabRegistrationMock).toHaveBeenCalledWith('page-1') + }) + it('wakes the requested page instead of the first worktree tab for page-scoped commands', async () => { const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true }) diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index dad2ae6f9ef..053f614663c 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -1335,6 +1335,13 @@ export class RuntimeBrowserCommands { const worktreeId = params.worktree ? (await this.host.resolveWorktreeSelector(params.worktree)).id : undefined + const sessionPartition = browserSessionRegistry.resolveKnownPartition(params.profileId) + if (!sessionPartition) { + throw new BrowserError( + 'invalid_argument', + `Browser profile ${params.profileId} was not found` + ) + } // Why: a desktop renderer mounts a ; a headless serve has none and // backs the page with a main-process offscreen WebContents instead. Both // register into BrowserManager so all downstream commands resolve uniformly. @@ -1356,6 +1363,7 @@ export class RuntimeBrowserCommands { url, worktreeId, params.profileId, + params.profileId ? sessionPartition : undefined, params.activate ) @@ -1460,7 +1468,8 @@ export class RuntimeBrowserCommands { win.webContents.send('browser:requestTabSetProfile', { requestId, browserPageId, - profileId: profile.id + profileId: profile.id, + sessionPartition: profile.partition }) }) @@ -1520,7 +1529,8 @@ export class RuntimeBrowserCommands { const created = await this.createBrowserTabInRenderer( sourceTab.url, sourceTab.worktreeId ?? target.worktreeId, - profile.id + profile.id, + profile.partition ) // Why: parity with browserTabCreate. Wait for the cloned tab's webview to // register so the returned browserPageId is operable by the next CLI call. @@ -1781,8 +1791,9 @@ export class RuntimeBrowserCommands { private async createBrowserTabInRenderer( url: string, - worktreeId?: string, - profileId?: string, + worktreeId: string | undefined, + profileId: string | undefined, + sessionPartition: string | undefined, activate?: boolean ): Promise<{ browserPageId: string }> { const win = this.host.getAuthoritativeWindow() @@ -1814,7 +1825,13 @@ export class RuntimeBrowserCommands { requestId, url, worktreeId, + // Why: leave sessionProfileId/sessionPartition undefined when no explicit + // profile was chosen so the renderer still applies the user's configured + // default-profile inheritance. Only thread the resolved partition when a + // profile is named — sending null here would suppress inheritance and + // force the shared default partition. sessionProfileId: profileId, + sessionPartition, activate }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b4b2dae941d..8b3a65720c1 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -52,6 +52,7 @@ import { recentTerminalOutputIncludesPath, type RuntimeTerminalAgentStatusEvent } from './orca-runtime' +import { HeadlessEmulator } from '../daemon/headless-emulator' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, @@ -719,6 +720,64 @@ function syncSinglePty( }) } +function makeDeferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function makeStatusFrame(index: number, first: boolean): string { + const pad = '·'.repeat(60) + const rows = [ + `✻ 执行任务中… (esc to interrupt) [${index}] ${pad}`, + ` ⎿ 正在分析代码库结构与依赖关系,请稍候… ${pad}`, + ` ⎿ tokens: ${1000 + index * 137} · elapsed: ${index}s ${pad}` + ] + return `${first ? '' : '\x1b[2A'}\r${rows.map((row) => `\x1b[K${row}`).join('\r\n')}\r` +} + +async function writeHeadless(emulator: HeadlessEmulator, data: string): Promise { + await emulator.write(data) +} + +function visibleNonEmptyLines(emulator: HeadlessEmulator): string[] { + return emulator.getVisibleLines().filter((line) => line.length > 0) +} + +async function parseHeadlessSnapshotLines( + snapshot: { data: string; cols: number; rows: number }, + display: { cols: number; rows: number } +): Promise { + const restored = new HeadlessEmulator({ cols: display.cols, rows: display.rows }) + try { + restored.resize(snapshot.cols, snapshot.rows) + await writeHeadless(restored, `\x1b[2J\x1b[3J\x1b[H${snapshot.data}`) + restored.resize(display.cols, display.rows) + return visibleNonEmptyLines(restored) + } finally { + restored.dispose() + } +} + +async function referenceStatusFrameLines( + spawn: { cols: number; rows: number }, + resized: { cols: number; rows: number } +): Promise { + const truth = new HeadlessEmulator({ cols: spawn.cols, rows: spawn.rows }) + try { + await writeHeadless(truth, 'user@host % claude\r\n') + truth.resize(resized.cols, resized.rows) + for (let index = 0; index < 5; index += 1) { + await writeHeadless(truth, makeStatusFrame(index, index === 0)) + } + return visibleNonEmptyLines(truth) + } finally { + truth.dispose() + } +} + const TEST_WINDOW_ID = 1 const TEST_REPO_ID = 'repo-1' const TEST_REPO_PATH = '/tmp/repo' @@ -5411,6 +5470,90 @@ describe('OrcaRuntimeService', () => { }) }) + it('resizes the headless mirror after an accepted desktop PTY resize', async () => { + const spawn = { cols: 80, rows: 24 } + const resized = { cols: 120, rows: 30 } + let currentSize = spawn + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => currentSize + }) + syncSinglePty(runtime, 'pty-1') + + runtime.onPtyData('pty-1', 'user@host % claude\r\n', 100) + currentSize = resized + runtime.onExternalPtyResize('pty-1', resized.cols, resized.rows) + for (let index = 0; index < 5; index += 1) { + runtime.onPtyData('pty-1', makeStatusFrame(index, index === 0), 200 + index) + } + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 5000 }) + expect(snapshot).toMatchObject({ cols: resized.cols, rows: resized.rows, source: 'headless' }) + await expect(parseHeadlessSnapshotLines(snapshot!, resized)).resolves.toEqual( + await referenceStatusFrameLines(spawn, resized) + ) + }) + + it('orders headless mirror resizes behind queued PTY writes', async () => { + const spawn = { cols: 80, rows: 10 } + const resized = { cols: 120, rows: 10 } + let currentSize = spawn + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => currentSize, + resize: () => { + currentSize = resized + return true + } + }) + syncSinglePty(runtime, 'pty-1') + runtime.onPtyData('pty-1', 'prompt\r\n', 100) + await runtime.serializeMainTerminalBuffer('pty-1') + + type HeadlessStateForTest = { + emulator: HeadlessEmulator + writeChain: Promise + } + const headless = ( + runtime as unknown as { headlessTerminals: Map } + ).headlessTerminals.get('pty-1') + expect(headless).toBeDefined() + const originalWrite = headless!.emulator.write.bind(headless!.emulator) + const queuedWriteStarted = makeDeferred() + const releaseQueuedWrite = makeDeferred() + headless!.emulator.write = async (data: string): Promise => { + queuedWriteStarted.resolve() + await releaseQueuedWrite.promise + await originalWrite(data) + } + + try { + runtime.onPtyData('pty-1', '\x1b[90GOLD', 200) + await queuedWriteStarted.promise + await runtime.updateDesktopViewport('pty-1', resized) + runtime.onPtyData('pty-1', '\r\nNEXT', 300) + releaseQueuedWrite.resolve() + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 100 }) + expect(snapshot).toMatchObject({ cols: resized.cols, rows: resized.rows }) + await expect(parseHeadlessSnapshotLines(snapshot!, resized)).resolves.toEqual([ + 'prompt', + ' O', + 'LD', + 'NEXT' + ]) + } finally { + headless!.emulator.write = originalWrite + releaseQueuedWrite.resolve() + } + }) + it('adopts renderer-seeded titles into headless main terminal snapshots', async () => { const artifactPath = '/tmp/renderer-seeded-artifact.json' const serializeBuffer = vi.fn().mockResolvedValue({ @@ -10354,6 +10497,112 @@ describe('OrcaRuntimeService', () => { expect(read.tail).toEqual(['ready']) }) + it('recovers exported ORCA_TERMINAL_HANDLE from discovered live PTY sessions', async () => { + const runtime = new OrcaRuntimeService(store) + const writes: string[] = [] + runtime.setPtyController({ + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-1', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_exported' + } + ] + }) + + const listed = await runtime.listTerminals() + expect(listed.terminals[0]?.handle).toBe('term_exported') + + runtime.onPtyData('pty-1', 'after restart\n', 100) + await expect(runtime.readTerminal('term_exported')).resolves.toMatchObject({ + handle: 'term_exported', + tail: ['after restart'] + }) + await expect( + runtime.sendTerminal('term_exported', { text: 'still writable' }) + ).resolves.toMatchObject({ + handle: 'term_exported', + accepted: true + }) + expect(writes).toEqual(['still writable']) + }) + + it('does not adopt a discovered terminal handle already bound to another live PTY', async () => { + const runtime = new OrcaRuntimeService(store) + const writesByPty = new Map() + runtime.setPtyController({ + write: (ptyId, data) => { + writesByPty.set(ptyId, [...(writesByPty.get(ptyId) ?? []), data]) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-victim', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_victim' + }, + { + id: 'pty-imposter', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_victim' + } + ] + }) + + const listed = await runtime.listTerminals() + const handles = listed.terminals.map((terminal) => terminal.handle) + expect(handles).toContain('term_victim') + expect(new Set(handles).size).toBe(handles.length) + + await expect( + runtime.sendTerminal('term_victim', { text: 'for victim' }) + ).resolves.toMatchObject({ accepted: true }) + expect(writesByPty.get('pty-victim')).toEqual(['for victim']) + expect(writesByPty.has('pty-imposter')).toBe(false) + }) + + it('keeps an already-bound terminal handle when discovery reports a different exported one', async () => { + const runtime = new OrcaRuntimeService(store) + const writes: string[] = [] + runtime.setPtyController({ + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-1', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_from_env' + } + ] + }) + runtime.registerPreAllocatedHandleForPty('pty-1', 'term_already_bound') + + const listed = await runtime.listTerminals() + expect(listed.terminals[0]?.handle).toBe('term_already_bound') + await expect( + runtime.sendTerminal('term_already_bound', { text: 'still routed' }) + ).resolves.toMatchObject({ accepted: true }) + expect(writes).toEqual(['still routed']) + // the reported-but-not-adopted handle must not resolve to the live pty + await expect(runtime.readTerminal('term_from_env')).rejects.toThrow() + }) + it('binds advertised URLs for renderer-restored PTYs that skip registerPty', () => { const runtime = new OrcaRuntimeService(store) @@ -12013,6 +12262,303 @@ describe('OrcaRuntimeService', () => { expect(getForegroundProcess).toHaveBeenCalledTimes(2) }) + it('normalizes Pi-compatible mobile session status to OMP for an unknown-launch foreground omp PTY', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(async () => 'omp') + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp') + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ) + expect(result.tabs[0]).not.toHaveProperty('launchAgent') + }) + + it('waits for unknown-launch foreground owner before publishing Pi-compatible mobile status', async () => { + const foregroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(() => foregroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp') + expect(events).toHaveLength(0) + + foregroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('keeps same-status Pi-compatible title changes queued behind the foreground owner probe', async () => { + const foregroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(() => foregroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + foregroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('coalesces same-status title frames behind one post-title foreground probe', async () => { + const staleForegroundProcess = deferred() + const freshForegroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi + .fn() + .mockReturnValueOnce(staleForegroundProcess.promise) + .mockReturnValueOnce(freshForegroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi done\x07', 125) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + staleForegroundProcess.resolve(null) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toHaveLength(0) + + freshForegroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('starts a post-title foreground probe when an older pending probe finds no owner', async () => { + const staleForegroundProcess = deferred() + const freshForegroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi + .fn() + .mockReturnValueOnce(staleForegroundProcess.promise) + .mockReturnValueOnce(freshForegroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + ;( + runtime as unknown as { + refreshPtyForegroundAgentFromController: (ptyId: string) => Promise + } + ).refreshPtyForegroundAgentFromController('pty-typed-omp') + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + staleForegroundProcess.resolve(null) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toHaveLength(0) + + freshForegroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('keeps Pi-compatible mobile session status as Pi for an unknown-launch foreground pi PTY', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-pi' }) + const getForegroundProcess = vi.fn(async () => 'pi') + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-pi-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + runtime.onPtyData('pty-typed-pi', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-pi') + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'Pi ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'pi', + terminalHandle: terminal.handle, + terminalTitle: 'Pi ready' + }) + }) + ) + expect(result.tabs[0]).not.toHaveProperty('launchAgent') + }) + it('keeps renderer-vetted mobile agent status for custom-titled terminals', async () => { const runtime = new OrcaRuntimeService(store) const leafId = '11111111-1111-4111-8111-111111111111' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 81150a3a36b..bbfa9731182 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -20,6 +20,7 @@ import { type AgentStatusEntry } from '../../shared/agent-status-types' import { + hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner } from '../../shared/agent-title-owner' @@ -661,7 +662,7 @@ import { closeLocalWatcherForWorktreePath } from '../ipc/filesystem-watcher' import { HeadlessEmulator, type HeadlessEmulatorOptions } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' -import type { IFilesystemProvider, IPtyProvider } from '../providers/types' +import type { IFilesystemProvider, IPtyProvider, PtyProcessInfo } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { assertFolderWorkspacePathUsable, @@ -942,6 +943,12 @@ type RuntimePtyWorktreeRecord = { waitBlockedAt: number | null } +type PtyForegroundAgentRefresh = { + promise: Promise + startedAfterTitleObservation: number + requestedAfterTitleObservation: number +} + function copySleepingAgentLaunchConfig( config: SleepingAgentLaunchConfig ): SleepingAgentLaunchConfig { @@ -1037,7 +1044,7 @@ type RuntimePtyController = { hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean - listProcesses?(): Promise<{ id: string; cwd: string; title: string }[]> + listProcesses?(): Promise serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } @@ -1925,7 +1932,8 @@ export class OrcaRuntimeService { private resolvedWorktreeGeneration = 0 private cloneInFlightByPath = new Map>() private agentDetector: AgentDetector | null = null - private ptyForegroundAgentRefreshes = new Map>() + private ptyForegroundAgentRefreshes = new Map() + private ptyDelayedForegroundSnapshotTitleObservations = new Map() private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() // Why: mobile clients subscribe to terminal output via terminal.subscribe. @@ -3253,7 +3261,7 @@ export class OrcaRuntimeService { /** * Publishes a PTY-backed terminal tab snapshot to the synced mobile session, - * normalizing Pi-compatible titles based on launch ownership. + * normalizing Pi-compatible titles based on launch or foreground ownership. */ private publishPtyBackedMobileSessionTerminal( worktreeId: string, @@ -3269,9 +3277,10 @@ export class OrcaRuntimeService { } ): void { const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + const ownerAgent = pty.launchAgent ?? pty.foregroundAgent const title = normalizeCompatibleAgentTitleForOwner( args.title ?? getLatestPtyTitle(pty) ?? 'Terminal', - pty.launchAgent + ownerAgent ) const existingTab = existing?.tabs.find( (candidate): candidate is RuntimeMobileSessionTerminalTab => @@ -5041,6 +5050,46 @@ export class OrcaRuntimeService { } } + private adoptControllerTerminalHandle(ptyId: string, handle: string | undefined): void { + const trimmed = handle?.trim() + if (!trimmed || !trimmed.startsWith('term_')) { + return + } + if (this.isTerminalHandleAdoptionBlocked(ptyId, trimmed)) { + return + } + // Why: after an app/runtime restart, the live PTY child still has its + // original ORCA_TERMINAL_HANDLE, but the runtime's in-memory map is gone. + this.registerPreAllocatedHandleForPty(ptyId, trimmed) + } + + // Why: adoption is best-effort restart recovery and must be first-wins. + // Re-keying a pty that already has a handle this session would strand + // waiters registered under the old handle, and provider-reported values + // are not trusted to be collision-free — a handle bound to a different + // pty must never be stolen by a later report. + private isTerminalHandleAdoptionBlocked(ptyId: string, handle: string): boolean { + if (this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId)) { + return true + } + for (const leaf of this.getLeavesForPty(ptyId)) { + const issued = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (issued && issued !== handle) { + return true + } + } + const existingRecord = this.handles.get(handle) + if (existingRecord && existingRecord.ptyId !== ptyId) { + return true + } + for (const [otherPtyId, otherHandle] of this.handleByPtyId) { + if (otherHandle === handle && otherPtyId !== ptyId) { + return true + } + } + return false + } + onPtySpawned(ptyId: string): void { const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (pty) { @@ -5154,11 +5203,25 @@ export class OrcaRuntimeService { if (agentStatus === 'idle' && prevStatus !== 'idle') { this.resolvePtyTuiIdleWaiters(pty, ptyId) } + const shouldDelayMobileSnapshot = + shouldTouchPtyBackedSessionTabs && + this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, oscTitle) + let foregroundRefresh: Promise | undefined // Why: gate on an actual status transition — braille spinner frames // mutate the title every tick, so probing per-title-change would stream // a foreground query per frame during active work. if (prevStatus !== pty.lastAgentStatus) { - this.refreshPtyForegroundAgent(ptyId) + foregroundRefresh = this.refreshPtyForegroundAgentFromController(ptyId, { + afterTitleObservation: observedAt + }) + } else if (shouldDelayMobileSnapshot) { + // Why: same-status compatible title changes can arrive before the + // foreground owner probe settles; publishing them would flicker. + foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt) + } + if (foregroundRefresh && shouldDelayMobileSnapshot) { + shouldTouchPtyBackedSessionTabs = false + this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) } } } @@ -5760,7 +5823,21 @@ export class OrcaRuntimeService { } private resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void { - this.headlessTerminals.get(ptyId)?.emulator.resize(cols, rows) + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return + } + // Why: terminal reflow is a parser operation. It must sit in the same + // per-PTY stream as output bytes or restore snapshots can bake in wraps + // from the wrong terminal width. + state.writeChain = state.writeChain + .then(() => { + state.emulator.resize(cols, rows) + }) + .catch(() => { + // Best-effort mirror tracking; live PTY streaming must continue even + // if xterm rejects a raced resize during teardown. + }) } private createHeadlessEmulator( @@ -7052,7 +7129,6 @@ export class OrcaRuntimeService { if (!resized) { return false } - this.resizeHeadlessTerminal(ptyId, cols, rows) this.onExternalPtyResize(ptyId, cols, rows) return true } @@ -7880,12 +7956,9 @@ export class OrcaRuntimeService { } } - // Why: called from the pty:resize IPC handler whenever the desktop renderer - // resizes a PTY (e.g. via safeFit after window resize, split, or desktop-mode - // restore). Stores the renderer-reported size so handleMobileSubscribe can use - // the actual pane geometry instead of a stale PTY size for previousCols. - // This is a passive geometry report — it does NOT call applyLayout; the - // PTY is already at the reported size. + // Why: called after a desktop renderer path has successfully resized the + // PTY (local IPC or remote desktop viewport). The runtime mirror must take + // the same accepted geometry so hidden-output restore parses at PTY width. onExternalPtyResize(ptyId: string, cols: number, rows: number): void { // The pty:resize IPC handler is supposed to gate via `isResizeSuppressed` // before calling here, but defend against callers that don't. @@ -7905,6 +7978,7 @@ export class OrcaRuntimeService { if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) { return } + this.resizeHeadlessTerminal(ptyId, cols, rows) this.refreshRendererGeometry(ptyId, cols, rows) } @@ -8667,6 +8741,15 @@ export class OrcaRuntimeService { } } + private shouldDelayPtyBackedMobileSnapshotForForegroundAgent( + pty: RuntimePtyWorktreeRecord, + title: string + ): boolean { + return ( + !pty.launchAgent && pty.foregroundAgent === null && hasCompatibleAgentTitleIdentity(title) + ) + } + /** * Schedules an asynchronous query to check which agent process is currently * running in the foreground of a PTY. @@ -8675,19 +8758,75 @@ export class OrcaRuntimeService { void this.refreshPtyForegroundAgentFromController(ptyId) } + private getPendingForegroundAgentRefreshForTitle( + ptyId: string, + titleObservedAt: number + ): Promise | undefined { + if (!this.ptyForegroundAgentRefreshes.has(ptyId)) { + return undefined + } + return this.refreshPtyForegroundAgentFromController(ptyId, { + afterTitleObservation: titleObservedAt + }) + } + + private delayPtyBackedMobileSnapshotForForegroundAgent( + ptyId: string, + titleObservedAt: number, + foregroundRefresh: Promise + ): void { + this.ptyDelayedForegroundSnapshotTitleObservations.set(ptyId, titleObservedAt) + void foregroundRefresh.then((foregroundAgentChanged) => { + if (this.ptyDelayedForegroundSnapshotTitleObservations.get(ptyId) !== titleObservedAt) { + return + } + this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) + if (!foregroundAgentChanged) { + this.touchMobileSessionSnapshotsForPty(ptyId) + } + }) + } + /** * Deduplicates and manages in-flight foreground agent refresh queries * for a specific PTY. */ - private refreshPtyForegroundAgentFromController(ptyId: string): Promise { + private refreshPtyForegroundAgentFromController( + ptyId: string, + options: { afterTitleObservation?: number } = {} + ): Promise { + const startedAfterTitleObservation = options.afterTitleObservation ?? 0 const pendingRefresh = this.ptyForegroundAgentRefreshes.get(ptyId) if (pendingRefresh) { - return pendingRefresh + pendingRefresh.requestedAfterTitleObservation = Math.max( + pendingRefresh.requestedAfterTitleObservation, + startedAfterTitleObservation + ) + return pendingRefresh.promise } - const refresh = this.loadPtyForegroundAgentFromController(ptyId).finally(() => { - this.ptyForegroundAgentRefreshes.delete(ptyId) + const entry: PtyForegroundAgentRefresh = { + promise: Promise.resolve(false), + startedAfterTitleObservation, + requestedAfterTitleObservation: startedAfterTitleObservation + } + const refresh = (async (): Promise => { + while (true) { + entry.startedAfterTitleObservation = entry.requestedAfterTitleObservation + const foregroundAgentChanged = await this.loadPtyForegroundAgentFromController(ptyId) + if ( + foregroundAgentChanged || + entry.requestedAfterTitleObservation <= entry.startedAfterTitleObservation + ) { + return foregroundAgentChanged + } + } + })().finally(() => { + if (this.ptyForegroundAgentRefreshes.get(ptyId) === entry) { + this.ptyForegroundAgentRefreshes.delete(ptyId) + } }) - this.ptyForegroundAgentRefreshes.set(ptyId, refresh) + entry.promise = refresh + this.ptyForegroundAgentRefreshes.set(ptyId, entry) return refresh } @@ -8695,34 +8834,35 @@ export class OrcaRuntimeService { * Queries the PTY controller for the active foreground process, identifies if it * is a recognized agent, and updates the PTY's foreground agent state if changed. */ - private async loadPtyForegroundAgentFromController(ptyId: string): Promise { + private async loadPtyForegroundAgentFromController(ptyId: string): Promise { if (!this.ptyController) { - return + return false } const pty = this.ptysById.get(ptyId) if (!pty?.connected) { - return + return false } // Why: foregroundAgent is only consulted as the owner fallback when // launchAgent is unknown, so a known launchAgent makes the relay // getForegroundProcess round-trip pure waste (covers all launched agents). if (pty.launchAgent) { - return + return false } let foregroundProcess: string | null try { foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) } catch { - return + return false } const foregroundAgent = foregroundProcess ? (recognizeAgentProcess(foregroundProcess)?.agent ?? null) : null if (pty.foregroundAgent === foregroundAgent) { - return + return false } pty.foregroundAgent = foregroundAgent this.touchMobileSessionSnapshotsForPty(ptyId) + return true } private getFreshExplicitAgentStatusForHandle(handle: string): { @@ -17778,6 +17918,7 @@ export class OrcaRuntimeService { const sessions = sessionsResult.value const livePtyIds = new Set(sessions.map((session) => session.id)) for (const session of sessions) { + this.adoptControllerTerminalHandle(session.id, session.terminalHandle) const worktreeId = inferWorktreeIdFromPtyId(session.id) ?? findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 35bf793840a..e5cb1ffb907 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2541,13 +2541,19 @@ export type PreloadApi = { requestId: string url: string worktreeId?: string - sessionProfileId?: string + sessionProfileId?: string | null + sessionPartition?: string activate?: boolean }) => void ) => () => void replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }) => void onRequestTabSetProfile: ( - callback: (data: { requestId: string; browserPageId: string; profileId: string }) => void + callback: (data: { + requestId: string + browserPageId: string + profileId: string + sessionPartition?: string + }) => void ) => () => void replyTabSetProfile: (reply: { requestId: string; error?: string }) => void onRequestTabClose: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index 333e298eabe..40a539ba02c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3030,7 +3030,8 @@ const api = { requestId: string url: string worktreeId?: string - sessionProfileId?: string + sessionProfileId?: string | null + sessionPartition?: string activate?: boolean }) => void ): (() => void) => { @@ -3040,7 +3041,8 @@ const api = { requestId: string url: string worktreeId?: string - sessionProfileId?: string + sessionProfileId?: string | null + sessionPartition?: string activate?: boolean } ) => callback(data) @@ -3055,11 +3057,21 @@ const api = { ipcRenderer.send('browser:tabCreateReply', reply) }, onRequestTabSetProfile: ( - callback: (data: { requestId: string; browserPageId: string; profileId: string }) => void + callback: (data: { + requestId: string + browserPageId: string + profileId: string + sessionPartition?: string + }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { requestId: string; browserPageId: string; profileId: string } + data: { + requestId: string + browserPageId: string + profileId: string + sessionPartition?: string + } ) => callback(data) ipcRenderer.on('browser:requestTabSetProfile', listener) return () => ipcRenderer.removeListener('browser:requestTabSetProfile', listener) diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts index 9dca8777834..03d0fe14aed 100644 --- a/src/relay/git-handler-utils.test.ts +++ b/src/relay/git-handler-utils.test.ts @@ -1,5 +1,35 @@ import { describe, expect, it } from 'vitest' import { parseStatusOutput } from './git-status-output-parser' +import { isUnsupportedWorktreeListZError } from './git-handler-utils' + +describe('isUnsupportedWorktreeListZError', () => { + it('detects an unknown-switch usage error from stderr when the exit code is absent', () => { + // Isolates the regex fallback: no numeric code, so only the stderr text + // (a runner that dropped the exit code) can classify the rejection. + const error = Object.assign(new Error('worktree list -z'), { + stderr: "error: unknown switch `z'\nusage: git worktree list []\n" + }) + expect(isUnsupportedWorktreeListZError(error)).toBe(true) + }) + + it('detects a localized (non-English) usage error via exit code 129', () => { + // The SSH remote may run under a non-English locale where the stderr text is + // translated; the numeric exit code must still classify the -z rejection. + const error = Object.assign(new Error('worktree list -z'), { + code: 129, + stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list []\n' + }) + expect(isUnsupportedWorktreeListZError(error)).toBe(true) + }) + + it('does not classify a fatal (exit 128) error as an unsupported -z rejection', () => { + const error = Object.assign(new Error('fatal'), { + code: 128, + stderr: 'fatal: unable to read tree\n' + }) + expect(isUnsupportedWorktreeListZError(error)).toBe(false) + }) +}) describe('parseStatusOutput', () => { it('parses upstream ahead/behind from porcelain v2 branch headers', () => { diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index 5ddd15fd896..f0a395da27b 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -147,10 +147,20 @@ function getErrorText(error: unknown): string { return String(error) } +function getErrorCode(error: unknown): string | undefined { + return typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code) + : undefined +} + export function isUnsupportedWorktreeListZError(error: unknown): boolean { - return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test( - getErrorText(error) - ) + // `-z` is this command's only flag older Git (<2.36) lacks, so its usage exit + // 129 signals the rejection in any locale; key for SSH remotes on old Git. + if (getErrorCode(error) === '129') { + return true + } + + return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error)) } export function parseWorktreeList( diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 5f4fff86610..126f761333b 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -62,6 +62,7 @@ type ManagedPty = { paneKey?: string tabId?: string worktreeId?: string + terminalHandle?: string startupCommand?: ManagedStartupCommand } @@ -156,6 +157,13 @@ function resolvePtyShellOverride(shellOverride: string): string { return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride } +type PtyProcessSummary = { + id: string + cwd: string + title: string + terminalHandle?: string +} + type SerializedPtyEntry = { id: string pid: number @@ -165,6 +173,7 @@ type SerializedPtyEntry = { paneKey?: string tabId?: string worktreeId?: string + terminalHandle?: string } export type PtyExitListener = (event: { id: string; paneKey?: string }) => void @@ -539,6 +548,10 @@ export class PtyHandler { // for overlay resolution; runtime-owned PTYs opt into relay delivery // because no renderer TerminalPane exists to type the command. const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined + // Why: kept so a restarted runtime can re-adopt this live PTY under its + // originally-exported handle (reported via listProcesses, survives revive). + const terminalHandle = + typeof env?.ORCA_TERMINAL_HANDLE === 'string' ? env.ORCA_TERMINAL_HANDLE : undefined const command = typeof params.command === 'string' ? params.command : undefined const terminalWindowsWslDistro = typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null @@ -589,6 +602,7 @@ export class PtyHandler { paneKey, tabId, worktreeId, + ...(terminalHandle ? { terminalHandle } : {}), ...(shouldProviderDeliverCommand ? { startupCommand: { @@ -819,12 +833,17 @@ export class PtyHandler { return await getForegroundProcessName(managed.pty.pid, managed.pty.process || null) } - private async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { - const results: { id: string; cwd: string; title: string }[] = [] + private async listProcesses(): Promise { + const results: PtyProcessSummary[] = [] for (const [id, managed] of this.ptys) { const title = (await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)) || 'shell' - results.push({ id, cwd: managed.initialCwd, title }) + results.push({ + id, + cwd: managed.initialCwd, + title, + ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) + }) } return results } @@ -846,7 +865,8 @@ export class PtyHandler { cwd: managed.initialCwd, paneKey: managed.paneKey, tabId: managed.tabId, - worktreeId: managed.worktreeId + worktreeId: managed.worktreeId, + ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) } return JSON.stringify(entries) @@ -884,6 +904,9 @@ export class PtyHandler { if (entry.worktreeId) { revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId } + if (entry.terminalHandle) { + revivedEnv.ORCA_TERMINAL_HANDLE = entry.terminalHandle + } const shell = resolveDefaultShell() // Why: `command` is intentionally absent from this revive path because // SerializedPtyEntry (see line 99) does not persist it — ManagedPty @@ -914,7 +937,8 @@ export class PtyHandler { buffered: '', paneKey: entry.paneKey, tabId: entry.tabId, - worktreeId: entry.worktreeId + worktreeId: entry.worktreeId, + ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) }) // Why: nextId starts at 1 and is only incremented by spawn(). Revived diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 97d3cbb4744..d9763b3e341 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -219,6 +219,12 @@ pointer-events: none; } +.pane-drop-overlay[data-pane-drop-overlay-kind='insertion'] { + background: var(--chart-2); + border: 0; + border-radius: 0; +} + .tab-drop-overlay__label { font-size: 11px; color: var(--primary-foreground); diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 05d609f6d35..9e79671f25a 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -362,8 +362,8 @@ export default function Landing(): React.JSX.Element { onClick={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })} > - {translate('auto.components.Landing.76a95f7f47', 'Create')} - {createTargetLabel} + {translate('auto.components.Landing.76a95f7f47', 'Create')}{' '} + {createTargetLabel.toLowerCase()} diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx index b63dc248665..5e45c6e71da 100644 --- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx @@ -11,6 +11,11 @@ import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownTool import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + getRichMarkdownSpellcheckAttribute, + useRichMarkdownSpellcheckAttribute +} from '@/components/editor/rich-markdown-spellcheck' type LinearIssueMarkdownDescriptionEditorProps = { value: string @@ -46,6 +51,9 @@ export function LinearIssueMarkdownDescriptionEditor({ const language = i18n.resolvedLanguage ?? i18n.language const lastEditorMarkdownRef = useRef(value) const editorRef = useRef(null) + const richMarkdownSpellcheckEnabled = useAppStore( + (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true + ) const linearIssueMarkdownExtensions = useMemo(() => { // Why: Tiptap freezes extension options when the editor is created; the // language value is the recreation key for translated extension options. @@ -63,7 +71,7 @@ export function LinearIssueMarkdownDescriptionEditor({ editorProps: { attributes: { class: 'rich-markdown-editor', - spellcheck: 'true', + spellcheck: getRichMarkdownSpellcheckAttribute(richMarkdownSpellcheckEnabled), 'aria-label': 'Issue description' }, handleKeyDown: (_view, event) => { @@ -93,6 +101,7 @@ export function LinearIssueMarkdownDescriptionEditor({ }, [language] ) + useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) useEffect(() => { editorRef.current = editor diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 8a17d67af80..335d5f15308 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -63,6 +63,7 @@ import { getEffectiveLayoutForWorktree as getEffectiveLayout, anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout } from './terminal/split-group-mount' +import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs' import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue' @@ -973,8 +974,7 @@ function Terminal(): React.JSX.Element | null { return } createBrowserTab(activeWorktreeId, source.url, { - title: source.title, - sessionProfileId: source.sessionProfileId + ...buildDuplicatedBrowserTabOptions(source) }) }, [activeWorktreeId, createBrowserTab] diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 84e084d7693..4896ecd390c 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -77,14 +77,12 @@ import { browserViewportPresetToOverride, getBrowserViewportPreset } from '../../../../shared/browser-viewport-presets' -import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences' import { rememberLiveBrowserUrl } from './browser-runtime' +import { ensureBrowserPageWebview } from './browser-page-webview' import { destroyPersistentWebview, moveFocusToRendererBeforeWebviewDetach, - registerPersistentWebview, - registeredWebContentsIds, - webviewRegistry + registeredWebContentsIds } from './webview-registry' import { applyBrowserPageViewportLayout, @@ -869,6 +867,7 @@ export default function BrowserPane({ workspaceId={browserTab.id} worktreeId={browserTab.worktreeId} sessionProfileId={browserTab.sessionProfileId ?? null} + sessionPartition={browserTab.sessionPartition ?? null} isActive={isActive && page.id === activeBrowserPage?.id} isAutomationVisible={automationVisiblePageIds.has(page.id)} isMobileDriven={mobileDrivenPageIds.has(page.id)} @@ -2648,6 +2647,7 @@ function BrowserPagePane({ workspaceId, worktreeId, sessionProfileId, + sessionPartition, isActive, isAutomationVisible, isMobileDriven, @@ -2659,6 +2659,7 @@ function BrowserPagePane({ workspaceId: string worktreeId: string sessionProfileId: string | null + sessionPartition: string | null isActive: boolean isAutomationVisible: boolean isMobileDriven: boolean @@ -2814,7 +2815,7 @@ function BrowserPagePane({ const sessionProfile = sessionProfileId ? (browserSessionProfiles.find((p) => p.id === sessionProfileId) ?? null) : null - const webviewPartition = sessionProfile?.partition ?? ORCA_BROWSER_PARTITION + const webviewPartition = sessionPartition ?? sessionProfile?.partition ?? ORCA_BROWSER_PARTITION const browserSessionImportState = useAppStore((s) => s.browserSessionImportState) const clearBrowserSessionImportState = useAppStore((s) => s.clearBrowserSessionImportState) const showBrowserZoomFeedback = useCallback((level: number): void => { @@ -3576,31 +3577,23 @@ function BrowserPagePane({ return } - let webview = webviewRegistry.get(browserTab.id) - let needsInitialNavigation = false - let needsInitialDefaultZoom = false - if (webview && webview.parentElement !== container) { - // Why: moving an Electron webview between DOM parents can recreate the - // guest document. Treat unexpected parent drift as stale state instead. - destroyPersistentWebview(browserTab.id) - webview = undefined - container = ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null - if (!container) { - return - } + const ensuredWebview = ensureBrowserPageWebview({ + browserTabId: browserTab.id, + container, + inputLocked: inputLockedRef.current, + webviewPartition, + resolveContainer: () => + ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null + }) + if (!ensuredWebview) { + return } - if (webview && webview.getAttribute('partition') !== webviewPartition) { - // Why: Electron partitions are immutable after creation. If restored state - // or another store path changes the profile, the persisted guest must be - // replaced rather than parked/reused with the stale session. - destroyPersistentWebview(browserTab.id) - webview = undefined - container = ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null - if (!container) { - return - } - } - if (webview) { + container = ensuredWebview.container + const webview = ensuredWebview.webview + const needsInitialNavigation = ensuredWebview.created + let needsInitialDefaultZoom = ensuredWebview.created + + if (!ensuredWebview.created) { webview.style.pointerEvents = inputLockedRef.current ? 'none' : 'auto' syncNavigationState(webview) // Why: seed the ref with the store URL so the URL sync effect does not @@ -3609,35 +3602,6 @@ function BrowserPagePane({ // last navigation event. lastKnownWebviewUrlRef.current = normalizeBrowserNavigationUrl(browserTabUrlRef.current) ?? null - } else { - webview = document.createElement('webview') as Electron.WebviewTag - webview.setAttribute('partition', webviewPartition) - webview.setAttribute('allowpopups', '') - // Why: keep HTML fullscreen contained to the element instead of - // letting guest content resize the host BrowserWindow into native macOS - // fullscreen. Without this, requestFullscreen() resizes the OS window but - // exitFullscreen() has nothing to restore, leaving the pane stuck - // fullscreen (issue #6442). Containing fullscreen to the element makes - // exit reliable and fires fullscreenchange as guests expect. - // Why: the key must be camelCase — Electron's webpreferences - // parser spreads keys verbatim into WebPreferences, so a lowercase key is - // silently ignored. - webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE) - webview.style.display = 'flex' - webview.style.flex = '1' - webview.style.width = '100%' - webview.style.height = '100%' - webview.style.border = 'none' - webview.style.pointerEvents = inputLockedRef.current ? 'none' : 'auto' - // Why: default to white so sites that don't set an html/body background - // (e.g. httpbin.org/html) don't show through to Orca's dark chrome. Real - // browsers paint the viewport white by default; sites that specify their - // own background (including dark ones) still override this. - webview.style.background = '#ffffff' - registerPersistentWebview(browserTab.id, webview) - container.appendChild(webview) - needsInitialNavigation = true - needsInitialDefaultZoom = true } webviewRef.current = webview diff --git a/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts b/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts index fba96aa5f5a..203fce6fdbf 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts +++ b/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts @@ -1,38 +1,106 @@ -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences' -const BROWSER_PANE_SOURCE = resolve(__dirname, 'BrowserPane.tsx') +const registryMocks = vi.hoisted(() => ({ + destroyPersistentWebview: vi.fn(), + registerPersistentWebview: vi.fn(), + webviewRegistry: new Map() +})) -function browserPaneSource(): string { - return readFileSync(BROWSER_PANE_SOURCE, 'utf8') -} +vi.mock('./webview-registry', () => ({ + destroyPersistentWebview: registryMocks.destroyPersistentWebview, + registerPersistentWebview: registryMocks.registerPersistentWebview, + webviewRegistry: registryMocks.webviewRegistry +})) -function sourceBetween(source: string, start: string, end: string): string { - const startIndex = source.indexOf(start) - const endIndex = source.indexOf(end, startIndex + start.length) +import { ensureBrowserPageWebview } from './browser-page-webview' - expect(startIndex).toBeGreaterThanOrEqual(0) - expect(endIndex).toBeGreaterThan(startIndex) - - return source.slice(startIndex, endIndex) +function createContainer(id: string): HTMLDivElement { + const container = document.createElement('div') + container.dataset.testid = id + document.body.appendChild(container) + return container } describe('BrowserPane webview preferences', () => { - it('keeps HTML fullscreen contained inside the browser pane webview', () => { - const source = browserPaneSource() - const creationBlock = sourceBetween( - source, - "webview = document.createElement('webview')", - 'registerPersistentWebview' - ) + beforeEach(() => { + registryMocks.destroyPersistentWebview.mockReset() + registryMocks.registerPersistentWebview.mockReset() + registryMocks.webviewRegistry.clear() + document.body.innerHTML = '' + }) - expect(source).toContain( - "import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences'" + afterEach(() => { + document.body.innerHTML = '' + }) + + it('creates a webview with the resolved partition and shared guest webpreferences', () => { + const container = createContainer('initial') + + const ensuredWebview = ensureBrowserPageWebview({ + browserTabId: 'browser-page-1', + container, + inputLocked: false, + webviewPartition: 'persist:orca-browser-session-profile-1', + resolveContainer: () => container + }) + + expect(ensuredWebview).not.toBeNull() + expect(ensuredWebview?.created).toBe(true) + expect(ensuredWebview?.container).toBe(container) + expect(ensuredWebview?.webview.getAttribute('partition')).toBe( + 'persist:orca-browser-session-profile-1' ) - expect(creationBlock).toContain( - "webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE)" + expect(ensuredWebview?.webview.getAttribute('webpreferences')).toBe( + ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE ) - expect(creationBlock).not.toContain('disablehtmlfullscreenwindowresize') + expect(registryMocks.registerPersistentWebview).toHaveBeenCalledWith( + 'browser-page-1', + ensuredWebview?.webview + ) + expect(container.lastElementChild).toBe(ensuredWebview?.webview as unknown as Element) + }) + + it('remounts the webview in a refreshed container when the stored resolved partition changes', () => { + const staleContainer = createContainer('stale') + const staleWebview = document.createElement('webview') as Electron.WebviewTag + staleWebview.setAttribute('partition', 'persist:orca-browser') + staleContainer.appendChild(staleWebview) + registryMocks.webviewRegistry.set('browser-page-1', staleWebview) + + const refreshedContainer = document.createElement('div') + refreshedContainer.dataset.testid = 'refreshed' + const resolveContainer = vi.fn(() => { + if (!refreshedContainer.isConnected) { + document.body.appendChild(refreshedContainer) + } + return refreshedContainer + }) + registryMocks.destroyPersistentWebview.mockImplementation(() => { + staleWebview.remove() + staleContainer.remove() + registryMocks.webviewRegistry.delete('browser-page-1') + }) + + const ensuredWebview = ensureBrowserPageWebview({ + browserTabId: 'browser-page-1', + container: staleContainer, + inputLocked: true, + webviewPartition: 'persist:orca-browser-session-profile-1', + resolveContainer + }) + + expect(registryMocks.destroyPersistentWebview).toHaveBeenCalledWith('browser-page-1') + expect(resolveContainer).toHaveBeenCalledTimes(1) + expect(ensuredWebview).not.toBeNull() + expect(ensuredWebview?.created).toBe(true) + expect(ensuredWebview?.container).toBe(refreshedContainer) + expect(ensuredWebview?.webview).not.toBe(staleWebview) + expect(ensuredWebview?.webview.getAttribute('partition')).toBe( + 'persist:orca-browser-session-profile-1' + ) + expect(ensuredWebview?.webview.style.pointerEvents).toBe('none') + expect(refreshedContainer.lastElementChild).toBe(ensuredWebview?.webview as unknown as Element) }) }) diff --git a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx index 822bf5ddef7..7b0bae3619f 100644 --- a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx +++ b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx @@ -102,12 +102,14 @@ export function BrowserToolbarMenu({ return } const targetId = pendingSwitchProfileId ?? 'default' + const profile = browserSessionProfiles.find((p) => p.id === targetId) // Why: Must destroy before store update. The webviewRegistry is keyed by // workspace ID (stable across switches). Without explicit destroy, the mount // effect would reclaim the old webview with the stale partition. onDestroyWebview() - switchBrowserTabProfile(workspaceId, pendingSwitchProfileId) - const profile = browserSessionProfiles.find((p) => p.id === targetId) + // Why: persist the resolved partition alongside the profile so the tab stays + // isolated even if the renderer profile mirror is later stale (issue #6923). + switchBrowserTabProfile(workspaceId, pendingSwitchProfileId, profile?.partition ?? null) toast.success( translate( 'auto.components.browser.pane.BrowserToolbarMenu.3ccd29d771', @@ -147,7 +149,7 @@ export function BrowserToolbarMenu({ setNewProfileName('') onDestroyWebview() - switchBrowserTabProfile(workspaceId, profile.id) + switchBrowserTabProfile(workspaceId, profile.id, profile.partition) toast.success( translate( 'auto.components.browser.pane.BrowserToolbarMenu.a7a86702b3', diff --git a/src/renderer/src/components/browser-pane/browser-page-webview.ts b/src/renderer/src/components/browser-pane/browser-page-webview.ts new file mode 100644 index 00000000000..1179d0da63b --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-page-webview.ts @@ -0,0 +1,67 @@ +import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences' +import { + destroyPersistentWebview, + registerPersistentWebview, + webviewRegistry +} from './webview-registry' + +export function ensureBrowserPageWebview({ + browserTabId, + container, + inputLocked, + webviewPartition, + resolveContainer +}: { + browserTabId: string + container: HTMLDivElement + inputLocked: boolean + webviewPartition: string + resolveContainer: () => HTMLDivElement | null +}): { container: HTMLDivElement; created: boolean; webview: Electron.WebviewTag } | null { + let webview = webviewRegistry.get(browserTabId) + let created = false + let activeContainer = container + + // Why: a persisted guest must be torn down and rebuilt when its DOM parent + // drifted (moving a across parents can recreate the guest document) + // or when its partition no longer matches — Electron partitions are immutable + // after creation, so reuse would keep the stale session. Re-resolve the + // viewport container the teardown may have detached; bail if it is gone. + if ( + webview && + (webview.parentElement !== container || webview.getAttribute('partition') !== webviewPartition) + ) { + destroyPersistentWebview(browserTabId) + webview = undefined + const refreshedContainer = resolveContainer() + if (!refreshedContainer) { + return null + } + activeContainer = refreshedContainer + } + if (webview) { + webview.style.pointerEvents = inputLocked ? 'none' : 'auto' + return { container: activeContainer, created, webview } + } + + webview = document.createElement('webview') as Electron.WebviewTag + webview.setAttribute('partition', webviewPartition) + webview.setAttribute('allowpopups', '') + // Why: Electron spreads the webpreferences keys verbatim, so the shared + // camelCase attribute must stay intact for fullscreen containment to work. + webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE) + webview.style.display = 'flex' + webview.style.flex = '1' + webview.style.width = '100%' + webview.style.height = '100%' + webview.style.border = 'none' + webview.style.pointerEvents = inputLocked ? 'none' : 'auto' + // Why: some pages never paint a background, and a white viewport matches + // normal browser behavior instead of leaking Orca chrome through the guest. + webview.style.background = '#ffffff' + registerPersistentWebview(browserTabId, webview) + activeContainer.appendChild(webview) + created = true + + return { container: activeContainer, created, webview } +} diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index 820fa577de9..73049bcc149 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -357,7 +357,10 @@ describe('Cmd+J project and repo-group search', () => { ]) }) - it('uses project header keys for multi-setup projects on one host', () => { + it('splits independent same-host checkouts of one project into per-setup keys', () => { + // Why: two `cloned` checkouts share the project's remote identity but are + // distinct user clones; the palette follows the sidebar and surfaces each as + // its own jump target rather than collapsing them. See #5374. const results = searchCmdJProjectResults({ query: 'platform', projectGroups: [], @@ -369,6 +372,27 @@ describe('Cmd+J project and repo-group search', () => { ] }) + expect(results.map((result) => result.rowKey)).toEqual([ + 'project:project-1::setup:repo-1', + 'project:project-1::setup:repo-2' + ]) + }) + + it('keeps a provisioned runtime copy under one project key alongside a same-host checkout', () => { + // Why: a `provisioned` (recipe-created ephemeral) copy shares the project's + // remote identity but must not split the user's real checkout; it nests + // under the single project key. Mirrors the sidebar grouping. See #6320 / #5374. + const results = searchCmdJProjectResults({ + query: 'platform', + projectGroups: [], + repos: [repo('repo-1', 'platform-a'), repo('repo-2', 'platform-runtime')], + projects: [project('project-1', 'Platform')], + projectHostSetups: [ + setup('setup-1', 'project-1', 'local', 'repo-1'), + { ...setup('setup-2', 'project-1', 'local', 'repo-2'), setupMethod: 'provisioned' } + ] + }) + expect(results.map((result) => result.rowKey)).toEqual(['project:project-1']) }) diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index c4b90043207..13461edd9ca 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -21,6 +21,7 @@ import { isRichMarkdownContextCommandTarget, runRichMarkdownContextCommand } from './rich-markdown-context-command-routing' +import { useRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck' type RichMarkdownEditorProps = { fileId: string @@ -72,6 +73,7 @@ export default function RichMarkdownEditor({ }: RichMarkdownEditorProps): React.JSX.Element { const rootRef = useRef(null) const settings = useAppStore((s) => s.settings) + const richMarkdownSpellcheckEnabled = settings?.richMarkdownSpellcheckEnabled ?? true const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const activateMarkdownLink = useAppStore((s) => s.activateMarkdownLink) const addDiffComment = useAppStore((s) => s.addDiffComment) @@ -209,6 +211,7 @@ export default function RichMarkdownEditor({ worktreeRoot, runtimeEnvironmentId, isMac, + richMarkdownSpellcheckEnabled, settings, activateMarkdownLink, rootRef, @@ -246,6 +249,7 @@ export default function RichMarkdownEditor({ setSlashMenu: menu.setSlashMenu, setDocLinkMenu: menu.setDocLinkMenu }) + useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) // Why: use useLayoutEffect (synchronous cleanup) so the pending serialization // flush runs before useEditor's cleanup destroys the editor instance on tab diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts new file mode 100644 index 00000000000..20d969ca8f0 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts @@ -0,0 +1,90 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { Editor } from '@tiptap/react' +import type { DocLinkMenuState } from './rich-markdown-commands' +import { describe, expect, it, vi } from 'vitest' +import type { LinkBubbleState } from './RichMarkdownLinkBubble' +import { + createRichMarkdownEditorConfig, + type EditorConfigParams +} from './rich-markdown-editor-config' +import type { SlashMenuState } from './rich-markdown-slash-commands' + +function ref(current: T): MutableRefObject { + return { current } +} + +function stateSetter(): Dispatch> { + return vi.fn() as Dispatch> +} + +function getSpellcheckAttribute(config: ReturnType): string { + const attributes = config.editorProps?.attributes + return typeof attributes === 'function' + ? attributes({} as never).spellcheck + : (attributes?.spellcheck ?? '') +} + +function createConfigParams(overrides: Partial = {}): EditorConfigParams { + return { + content: '', + filePath: '/repo/README.md', + worktreeId: 'worktree-1', + worktreeRoot: '/repo', + runtimeEnvironmentId: null, + isMac: false, + richMarkdownSpellcheckEnabled: true, + settings: { activeRuntimeEnvironmentId: null }, + activateMarkdownLink: vi.fn(), + rootRef: ref(null), + editorRef: ref(null), + lastCommittedMarkdownRef: ref(''), + onContentChangeRef: ref(vi.fn()), + onDirtyStateHintRef: ref(vi.fn()), + onSaveRef: ref(vi.fn()), + onOpenDocLinkRef: ref(undefined), + isEditingLinkRef: ref(false), + slashMenuRef: ref(null), + filteredSlashCommandsRef: ref([]), + selectedCommandIndexRef: ref(0), + docLinkMenuRef: ref(null), + filteredDocLinkRowsRef: ref([]), + selectedDocLinkIndexRef: ref(0), + handleLocalImagePickRef: ref(vi.fn()), + handleEmojiPickRef: ref(vi.fn()), + typedEmptyOrderedListMarkerRef: ref(false), + cancelAutoFocusRef: ref(null), + serializeTimerRef: ref(null), + isInitializingRef: ref(false), + isApplyingProgrammaticUpdateRef: ref(false), + markdownCommentsRef: ref([]), + markdownSourceLineOffsetRef: ref(0), + flushPendingSerialization: vi.fn(), + openSearchRef: ref(vi.fn()), + syncAnnotationTarget: vi.fn(), + clearAnnotationTarget: vi.fn(), + scrollRichMarkdownReviewNoteCardIntoView: vi.fn(), + setIsEditingLink: stateSetter(), + setLinkBubble: stateSetter(), + setSelectedCommandIndex: stateSetter(), + setSelectedDocLinkIndex: stateSetter(), + setSlashMenu: stateSetter(), + setDocLinkMenu: stateSetter(), + ...overrides + } +} + +describe('createRichMarkdownEditorConfig', () => { + it('disables browser spellcheck when the rich Markdown setting is off', () => { + const config = createRichMarkdownEditorConfig( + createConfigParams({ richMarkdownSpellcheckEnabled: false }) + ) + + expect(getSpellcheckAttribute(config)).toBe('false') + }) + + it('keeps browser spellcheck enabled by default', () => { + const config = createRichMarkdownEditorConfig(createConfigParams()) + + expect(getSpellcheckAttribute(config)).toBe('true') + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index e6fedd8f897..de2e520d631 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -26,6 +26,7 @@ import { createRichMarkdownImageResolverContext, setRichMarkdownImageResolverContext } from './rich-markdown-image-context' +import { getRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck' import type { MutableRefObject, Dispatch, SetStateAction } from 'react' import type { DiffComment } from '../../../../shared/types' @@ -36,6 +37,7 @@ export type EditorConfigParams = { worktreeRoot: string | null runtimeEnvironmentId?: string | null isMac: boolean + richMarkdownSpellcheckEnabled: boolean settings: RichMarkdownRuntimeSettings activateMarkdownLink: ActivateMarkdownLink rootRef: MutableRefObject @@ -82,6 +84,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE worktreeRoot, runtimeEnvironmentId, isMac, + richMarkdownSpellcheckEnabled, settings, activateMarkdownLink, rootRef, @@ -127,7 +130,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE editorProps: { attributes: { class: 'rich-markdown-editor', - spellcheck: 'true' + spellcheck: getRichMarkdownSpellcheckAttribute(richMarkdownSpellcheckEnabled) }, handleDOMEvents: { cut: handleRichMarkdownCut diff --git a/src/renderer/src/components/editor/rich-markdown-spellcheck.ts b/src/renderer/src/components/editor/rich-markdown-spellcheck.ts new file mode 100644 index 00000000000..0b82bdf60ee --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-spellcheck.ts @@ -0,0 +1,12 @@ +import { useEffect } from 'react' +import type { Editor } from '@tiptap/react' + +export function getRichMarkdownSpellcheckAttribute(enabled: boolean): 'true' | 'false' { + return enabled ? 'true' : 'false' +} + +export function useRichMarkdownSpellcheckAttribute(editor: Editor | null, enabled: boolean): void { + useEffect(() => { + editor?.view.dom.setAttribute('spellcheck', getRichMarkdownSpellcheckAttribute(enabled)) + }, [editor, enabled]) +} diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index 4665a34cc91..228ddcbf53a 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -50,6 +50,7 @@ type FloatingPanelStoreState = { activate?: boolean focusAddressBar?: boolean sessionProfileId?: string | null + sessionPartition?: string | null title?: string targetGroupId?: string } @@ -1241,6 +1242,7 @@ describe('FloatingTerminalPanel close behavior', () => { canGoForward: false, loadError: null, sessionProfileId: 'profile-1', + sessionPartition: 'persist:orca-browser-session-profile-1', createdAt: 1 } ] @@ -1272,6 +1274,7 @@ describe('FloatingTerminalPanel close behavior', () => { { title: 'Example', sessionProfileId: 'profile-1', + sessionPartition: 'persist:orca-browser-session-profile-1', targetGroupId: 'floating-group', browserRuntimeEnvironmentId: null } diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 61f8ea3dd5b..8fa5e6a5258 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -28,6 +28,7 @@ import { appendUniqueOpenFileIds } from '@/components/terminal/unsaved-close-que import { getConnectionId } from '@/lib/connection-context' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' import { detectLanguage } from '@/lib/language-detect' +import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite' import { @@ -1490,8 +1491,7 @@ export function FloatingTerminalPanel({ return } createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, source.url, { - title: source.title, - sessionProfileId: source.sessionProfileId, + ...buildDuplicatedBrowserTabOptions(source), targetGroupId: activeGroup?.id, browserRuntimeEnvironmentId: null }) diff --git a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx index ba840a82421..9a4019a3f8b 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx @@ -25,6 +25,11 @@ import { hasBoundedGitHubMarkdownImageUrlText } from '@/components/github/github import { useImageInput } from '@/components/github/use-image-input' import type { GitHubOwnerRepo } from '../../../../shared/types' import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + getRichMarkdownSpellcheckAttribute, + useRichMarkdownSpellcheckAttribute +} from '@/components/editor/rich-markdown-spellcheck' type GitHubMarkdownComposerProps = { value: string @@ -59,6 +64,9 @@ export function GitHubMarkdownComposer({ const onSubmitShortcutRef = useRef(onSubmitShortcut) const disabledRef = useRef(disabled) const isEditingLinkRef = useRef(false) + const richMarkdownSpellcheckEnabled = useAppStore( + (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true + ) const [activeTab, setActiveTab] = useState('write') const [linkBubble, setLinkBubble] = useState(null) const [isEditingLink, setIsEditingLink] = useState(false) @@ -114,7 +122,7 @@ export function GitHubMarkdownComposer({ editorProps: { attributes: { class: cn('rich-markdown-editor github-markdown-composer-editor', minHeightClassName), - spellcheck: 'true' + spellcheck: getRichMarkdownSpellcheckAttribute(richMarkdownSpellcheckEnabled) }, handleKeyDown: (_view, event) => { if (isScreenSubmitShortcut(event)) { @@ -179,6 +187,7 @@ export function GitHubMarkdownComposer({ setLinkBubble(null) } }) + useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) useEffect(() => { if (!editor) { diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx index a668ccbc555..af45e066a3c 100644 --- a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx @@ -6,10 +6,11 @@ import type { MobileNetworkInterface } from '../settings/mobile-network-interfac // Why: MobileHero (mobile pairing screen) and MobileNetworkInterfaceSection // (Settings → Mobile) both need the same network selector. This wraps the -// generic AddressPicker with the mobile grammar (IPv4 / Tailscale *.ts.net) -// and copy. Discovered interfaces come from the OS; "Add custom address…" -// opens a dialog for an address the OS didn't surface — the only way to pair -// across networks. +// generic AddressPicker with the mobile grammar (IPv4, any RFC 1123 +// hostname — including Tailscale *.ts.net and DDNS domains — optionally +// with :port) and copy. Discovered interfaces come from the OS; "Add custom +// address…" opens a dialog for an address the OS didn't surface — the only +// way to pair across networks. export type NetworkInterfacePickerProps = { networkInterfaces: readonly MobileNetworkInterface[] @@ -81,11 +82,11 @@ export function NetworkInterfacePicker({ inputLabel: translate('auto.components.mobile.CustomNetworkAddressDialog.label', 'Address'), placeholder: translate( 'auto.components.mobile.CustomNetworkAddressDialog.placeholder', - 'my-mac.ts.net or 192.168.1.50' + 'my-mac.ts.net, home.example.com, or 192.168.1.50' ), hint: translate( 'auto.components.mobile.CustomNetworkAddressDialog.hint', - 'Enter an IP address or a Tailscale hostname (ends in .ts.net).' + 'Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.' ), cancel: translate('auto.components.mobile.CustomNetworkAddressDialog.cancel', 'Cancel'), confirm: translate('auto.components.mobile.CustomNetworkAddressDialog.use', 'Use address') diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts index c8c377566e1..c78190f42e9 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record = { const ANDROID_COPY: InstallCopy = { ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk' + url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk' } export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy { diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 7905e030639..f67c7d82a3a 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -210,6 +210,7 @@ export default function AiVaultPanel(): React.JSX.Element { const getSessionResumeState = useCallback( (session: AiVaultSession) => resolveAiVaultSessionResumeState({ + sessionFilePath: session.filePath, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, @@ -229,6 +230,7 @@ export default function AiVaultPanel(): React.JSX.Element { const getSessionResumeActions = useCallback( (session: AiVaultSession) => resolveAiVaultSessionResumeActions({ + sessionFilePath: session.filePath, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index fe2e07bc338..5ea6bd9d307 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -101,12 +101,13 @@ export function VaultSessionRow({ sessionId: session.sessionId, title: session.title, command: resumeStartup.command, + sessionFilePath: session.filePath, ...(resumeStartup.env ? { env: resumeStartup.env } : {}), ...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {}) }) window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_START_EVENT)) }, - [resumeDisabled, session.agent, session.sessionId, session.title, resumeStartup] + [resumeDisabled, session, resumeStartup] ) return ( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index ac5c07645fa..dc2ed601f83 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -12,8 +12,8 @@ import { } from '@/lib/worktree-activation' import { useAppStore } from '@/store' import { - getAiVaultResumeWorkspaceTargetStatus, - isSupportedAiVaultResumeTargetStatus + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' import type { Worktree } from '../../../../shared/types' @@ -78,6 +78,7 @@ export function useAiVaultSessionLaunchActions({ const handleResume = useCallback( (session: AiVaultSession, targetWorktreeId?: string): void => { const targetId = resolveAiVaultSessionLaunchTarget({ + sessionFilePath: session.filePath, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, targetWorktreeId, targetState @@ -93,17 +94,7 @@ export function useAiVaultSessionLaunchActions({ } if (targetId.status === 'unsupported') { - toast.error( - targetId.targetStatus === 'runtime' - ? translate( - 'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported', - 'Resume from history is not available in runtime-hosted workspaces.' - ) - : translate( - 'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace', - 'Open a local or SSH workspace before resuming a session.' - ) - ) + toast.error(aiVaultResumeUnsupportedMessage(targetId.targetStatus)) return } @@ -138,6 +129,7 @@ export type AiVaultSessionLaunchTarget = | { status: 'ready'; worktreeId: string } export function resolveAiVaultSessionLaunchTarget(args: { + sessionFilePath: string | null activeWorktreeId: string | null targetWorktreeId?: string targetState: AiVaultSessionResumeTargetState @@ -151,13 +143,36 @@ export function resolveAiVaultSessionLaunchTarget(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId) - if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) { + if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) { return { status: 'unsupported', targetStatus } } return { status: 'ready', worktreeId: targetWorktreeId } } +function aiVaultResumeUnsupportedMessage( + targetStatus: ReturnType +): string { + if (targetStatus === 'runtime') { + return translate( + 'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported', + 'Resume from history is not available in runtime-hosted workspaces.' + ) + } + // Why: 'ssh' only reaches the unsupported branch when the session file lives + // on this machine, so the message explains the host mismatch, not SSH itself. + if (targetStatus === 'ssh') { + return translate( + 'auto.components.right.sidebar.AiVaultPanel.localSessionSshWorkspaceUnsupported', + "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace', + 'Open a local or SSH workspace before resuming a session.' + ) +} + function activateAiVaultResumeWorkspace(workspaceId: string): void { const workspaceScope = parseWorkspaceKey(workspaceId) if (workspaceScope?.type === 'folder') { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts index a3cf98dd7f5..5e8bc3f4242 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -106,10 +106,15 @@ function makeWorktreeInfo( } } +const HOST_SESSION_FILE = '/Users/ada/.claude/projects/-repo-orca/session-1.jsonl' +const WSL_SESSION_FILE = + '\\\\wsl$\\Ubuntu\\home\\ada\\.claude\\projects\\-repo-orca\\session-1.jsonl' + describe('resolveAiVaultSessionResumeState', () => { it('prefers the session worktree over the active workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-1::/repo/other', worktrees: [ @@ -128,6 +133,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to the active workspace when the session worktree is unavailable', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: 'repo-1::/repo/orca', worktrees: [makeWorktree()], @@ -143,6 +149,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks missing worktrees even when the repo is SSH-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [], @@ -158,6 +165,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('allows SSH-owned session worktrees', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree()], @@ -173,6 +181,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('allows SSH-stamped worktrees even when the repo owner is runtime', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree({ hostId: 'ssh:ssh-1' })], @@ -188,6 +197,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks runtime-owned targets even when they have no SSH connection', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree()], @@ -208,6 +218,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks runtime-stamped worktrees even when the repo owner is local', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree({ hostId: 'runtime:env-1' })], @@ -223,6 +234,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('prefers the session worktree when the active workspace is SSH-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/remote/orca', worktrees: [ @@ -245,6 +257,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to the active workspace when the session worktree is runtime-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/repo/other', worktrees: [ @@ -275,6 +288,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to an active SSH folder workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -288,9 +302,27 @@ describe('resolveAiVaultSessionResumeState', () => { }) }) + it('blocks host-stored sessions when only an SSH workspace is open', () => { + expect( + resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, + worktreeInfo: makeWorktreeInfo('archived'), + activeWorktreeId: folderWorkspaceKey('folder-1'), + worktrees: [], + repos: [], + targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) + }) + ).toEqual({ + blocked: true, + worktreeId: null, + usesSessionWorktree: false + }) + }) + it('blocks an active runtime folder workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -309,6 +341,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('exposes separate session-worktree and active-workspace targets', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-1::/repo/other', worktrees: [ @@ -326,6 +359,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('enables an SSH active-workspace action when the session worktree is local', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/remote/orca', worktrees: [ @@ -344,9 +378,32 @@ describe('resolveAiVaultSessionResumeActions', () => { }) }) + it('disables the SSH active-workspace action for host-stored sessions', () => { + expect( + resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, + worktreeInfo: makeWorktreeInfo('active'), + activeWorktreeId: 'repo-2::/remote/orca', + worktrees: [ + makeWorktree(), + makeWorktree({ + id: 'repo-2::/remote/orca', + repoId: 'repo-2', + path: '/remote/orca' + }) + ], + repos: [{ id: 'repo-1' } as Repo, { id: 'repo-2', connectionId: 'ssh-1' } as Repo] + }) + ).toEqual({ + worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false }, + newTab: { worktreeId: 'repo-2::/remote/orca', disabled: true } + }) + }) + it('disables runtime-owned targets without disabling local targets', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/repo/other', worktrees: [ @@ -376,6 +433,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('does not expose the active workspace as a duplicate new-tab target', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('current'), activeWorktreeId: 'repo-1::/repo/orca', worktrees: [makeWorktree()], @@ -390,6 +448,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('enables the active folder workspace action when it is local or SSH-owned', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -405,6 +464,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('disables the active folder workspace action when it is runtime-owned', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -422,6 +482,7 @@ describe('resolveAiVaultSessionLaunchTarget', () => { it('allows direct resume into an active SSH folder workspace', () => { expect( resolveAiVaultSessionLaunchTarget({ + sessionFilePath: WSL_SESSION_FILE, activeWorktreeId: folderWorkspaceKey('folder-1'), targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) }) @@ -431,9 +492,23 @@ describe('resolveAiVaultSessionLaunchTarget', () => { }) }) + it('blocks direct resume of a host-stored session into an SSH folder workspace', () => { + expect( + resolveAiVaultSessionLaunchTarget({ + sessionFilePath: HOST_SESSION_FILE, + activeWorktreeId: folderWorkspaceKey('folder-1'), + targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) + }) + ).toEqual({ + status: 'unsupported', + targetStatus: 'ssh' + }) + }) + it('blocks direct resume into a runtime folder workspace', () => { expect( resolveAiVaultSessionLaunchTarget({ + sessionFilePath: WSL_SESSION_FILE, activeWorktreeId: folderWorkspaceKey('folder-1'), targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index 07091909d94..b8a428cd0c0 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -1,7 +1,7 @@ import type { Repo, Worktree } from '../../../../shared/types' import { - getAiVaultResumeWorkspaceTargetStatus, - isSupportedAiVaultResumeTargetStatus + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' @@ -33,6 +33,7 @@ export type AiVaultSessionResumeActions = { } export function resolveAiVaultSessionResumeState(args: { + sessionFilePath: string | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -54,6 +55,7 @@ export function resolveAiVaultSessionResumeState(args: { for (const worktreeId of candidateWorktreeIds) { const targetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, worktreeId, targetState }) @@ -75,6 +77,7 @@ export function resolveAiVaultSessionResumeState(args: { } export function resolveAiVaultSessionResumeActions(args: { + sessionFilePath: string | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -88,10 +91,12 @@ export function resolveAiVaultSessionResumeActions(args: { const targetState = resolveAiVaultResumeTargetState(args) const sessionTargetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, worktreeId: sessionWorktreeId, targetState }) const activeTargetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, worktreeId: args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId ? args.activeWorktreeId @@ -136,6 +141,7 @@ export function isKnownAiVaultResumeWorkspaceTarget( } function resolveSupportedResumeWorktreeId(args: { + sessionFilePath: string | null worktreeId: string | null targetState: AiVaultSessionResumeTargetState }): string | null { @@ -148,7 +154,7 @@ function resolveSupportedResumeWorktreeId(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, args.worktreeId) - if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) { + if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) { return null } diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx index 2d8ca10c2f7..a4a33f1102a 100644 --- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -16,6 +16,7 @@ import { SettingsSwitchRow } from './SettingsFormControls' import { translate } from '@/i18n/i18n' +import { RichMarkdownSpellcheckSetting } from './RichMarkdownSpellcheckSetting' export type AutoSaveDelayDraftState = { sourceDelayMs: number @@ -378,6 +379,8 @@ export function GeneralEditorSettingsSection({ /> + + { expect(matchesSettingsSearch('wsl', entries)).toBe(true) }) + it('includes rich Markdown spellcheck keywords', () => { + const entries = getGeneralPaneSearchEntries() + + expect(matchesSettingsSearch('spellcheck', entries)).toBe(true) + expect(matchesSettingsSearch('red underline', entries)).toBe(true) + }) + it('omits the default project runtime setting when Windows runtimes are unsupported', () => { const entries = getGeneralPaneSearchEntries({ includeProjectRuntime: false }) diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx index d6057f96f76..2ec4fcd516c 100644 --- a/src/renderer/src/components/settings/MobileSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx @@ -12,7 +12,7 @@ export { getMobileSettingsPaneSearchEntries } const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217' const ORCA_ANDROID_APK_URL = - 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk' + 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk' export function MobileSettingsPane(): React.JSX.Element { const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false) diff --git a/src/renderer/src/components/settings/RichMarkdownSpellcheckSetting.tsx b/src/renderer/src/components/settings/RichMarkdownSpellcheckSetting.tsx new file mode 100644 index 00000000000..c59a6302386 --- /dev/null +++ b/src/renderer/src/components/settings/RichMarkdownSpellcheckSetting.tsx @@ -0,0 +1,44 @@ +import type React from 'react' +import type { GlobalSettings } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { SearchableSetting } from './SearchableSetting' +import { SettingsSwitchRow } from './SettingsFormControls' + +type RichMarkdownSpellcheckSettingProps = { + settings: Pick + updateSettings: (updates: Partial) => void +} + +export function RichMarkdownSpellcheckSetting({ + settings, + updateSettings +}: RichMarkdownSpellcheckSettingProps): React.JSX.Element { + const enabled = settings.richMarkdownSpellcheckEnabled ?? true + + return ( + + updateSettings({ richMarkdownSpellcheckEnabled: !enabled })} + /> + + ) +} diff --git a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts index 5e8b643e111..0114ea3ebf4 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts +++ b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts @@ -8,12 +8,15 @@ vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { ...actual, - useState: (initial: T) => [ - themeTarget ?? initial, - (value: T) => { - themeTarget = value as 'dark' | 'light' - } - ] + useState: (initial: T | (() => T)) => { + const initialValue = typeof initial === 'function' ? (initial as () => T)() : initial + return [ + themeTarget ?? initialValue, + (value: T) => { + themeTarget = value as 'dark' | 'light' + } + ] + } } }) @@ -23,7 +26,10 @@ vi.mock('./TerminalSettingsPreview', () => ({ } })) -import { TerminalThemeCatalogSection } from './TerminalThemeSections' +import { + resetTerminalThemeTargetMemoryForTests, + TerminalThemeCatalogSection +} from './TerminalThemeSections' type ReactElementLike = { type: unknown @@ -50,6 +56,7 @@ const warpThemesMock: UseWarpThemeImportReturn = { function makeSettings(overrides: Partial = {}): GlobalSettings { return { + theme: 'system', terminalUseSeparateLightTheme: false, terminalThemeDark: 'Ghostty Default Style Dark', terminalThemeLight: 'Builtin Tango Light', @@ -64,12 +71,13 @@ function renderCatalog( settings = makeSettings(), updateSettings = vi.fn(), target?: 'dark' | 'light', - preferredTarget?: 'dark' | 'light' + preferredTarget?: 'dark' | 'light', + systemPrefersDark = true ): React.JSX.Element { themeTarget = target return TerminalThemeCatalogSection({ settings, - systemPrefersDark: true, + systemPrefersDark, themeSearch: '', setThemeSearch: () => {}, updateSettings, @@ -167,6 +175,7 @@ function findButtonTexts(node: unknown): string[] { describe('TerminalThemeCatalogSection', () => { beforeEach(() => { themeTarget = 'dark' + resetTerminalThemeTargetMemoryForTests() vi.clearAllMocks() }) @@ -180,6 +189,72 @@ describe('TerminalThemeCatalogSection', () => { ) }) + it('opens on the active light theme when the system appearance is light', () => { + const updateSettings = vi.fn() + const element = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + updateSettings, + undefined, + undefined, + false + ) + const picker = findElementByTypeName(element, 'ThemePicker') + const preview = findElementByTypeName(element, 'TerminalSettingsPreview') + const selectTheme = picker?.props?.onSelectTheme as (theme: string) => void + + expect(picker?.props?.selectedTheme).toBe('Builtin Tango Light') + expect(preview?.props?.modeOverride).toBe('light') + + selectTheme('GitHub Light') + + expect(updateSettings).toHaveBeenCalledWith({ terminalThemeLight: 'GitHub Light' }) + }) + + it('reopens the manually edited light target while the active appearance is dark', () => { + const firstOpen = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + vi.fn(), + undefined, + undefined, + true + ) + const targetControl = findElementByTypeName(firstOpen, 'SettingsSegmentedControl') + const selectTarget = targetControl?.props?.onChange as (target: 'dark' | 'light') => void + + selectTarget('light') + + const reopened = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + vi.fn(), + undefined, + undefined, + true + ) + const picker = findElementByTypeName(reopened, 'ThemePicker') + const preview = findElementByTypeName(reopened, 'TerminalSettingsPreview') + + expect(picker?.props?.selectedTheme).toBe('Builtin Tango Light') + expect(preview?.props?.modeOverride).toBe('light') + }) + + it('opens a customized light-only theme after reload even when the active appearance is dark', () => { + const element = renderCatalog( + makeSettings({ + terminalUseSeparateLightTheme: true, + terminalThemeLight: 'GitHub Light' + }), + vi.fn(), + undefined, + undefined, + true + ) + const picker = findElementByTypeName(element, 'ThemePicker') + const preview = findElementByTypeName(element, 'TerminalSettingsPreview') + + expect(picker?.props?.selectedTheme).toBe('GitHub Light') + expect(preview?.props?.modeOverride).toBe('light') + }) + it('keeps the light target enabled while separate light theme is disabled', () => { const element = renderCatalog(makeSettings({ terminalUseSeparateLightTheme: false }), vi.fn()) const targetControl = findElementByTypeName(element, 'SettingsSegmentedControl') diff --git a/src/renderer/src/components/settings/TerminalThemeSections.tsx b/src/renderer/src/components/settings/TerminalThemeSections.tsx index 9479b5f1738..1fa765bb271 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.tsx +++ b/src/renderer/src/components/settings/TerminalThemeSections.tsx @@ -12,12 +12,56 @@ import { TerminalSettingsPreview } from './TerminalSettingsPreview' import { WarpThemeImportButton } from './WarpThemeImportButton' import { YamlThemeImportButton } from './YamlThemeImportButton' import type { UseWarpThemeImportReturn } from './useWarpThemeImport' -import { getAvailableTerminalThemeOptions } from '@/lib/terminal-theme' +import { + DEFAULT_TERMINAL_THEME_DARK, + DEFAULT_TERMINAL_THEME_LIGHT, + getAvailableTerminalThemeOptions, + resolveEffectiveTerminalAppearance +} from '@/lib/terminal-theme' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' type TerminalThemeTarget = 'dark' | 'light' +let lastEditedTerminalThemeTarget: TerminalThemeTarget | null = null + +function rememberTerminalThemeTarget(target: TerminalThemeTarget): void { + lastEditedTerminalThemeTarget = target +} + +export function resetTerminalThemeTargetMemoryForTests(): void { + lastEditedTerminalThemeTarget = null +} + +function isCustomizedTheme(themeName: string, defaultThemeName: string): boolean { + const trimmed = themeName.trim() + return trimmed.length > 0 && trimmed !== defaultThemeName +} + +function getInitialTerminalThemeTarget( + settings: GlobalSettings, + systemPrefersDark: boolean, + preferredTarget?: TerminalThemeTarget +): TerminalThemeTarget { + if (preferredTarget) { + return preferredTarget + } + if (lastEditedTerminalThemeTarget) { + return lastEditedTerminalThemeTarget + } + const customizedDark = isCustomizedTheme(settings.terminalThemeDark, DEFAULT_TERMINAL_THEME_DARK) + const customizedLight = isCustomizedTheme( + settings.terminalThemeLight, + DEFAULT_TERMINAL_THEME_LIGHT + ) + if (customizedDark !== customizedLight) { + return customizedDark ? 'dark' : 'light' + } + // Why: the picker edits mode-specific slots. Defaulting to the active + // terminal mode makes the selected theme apply immediately in system-light. + return resolveEffectiveTerminalAppearance(settings, systemPrefersDark).mode +} + type TerminalThemeCatalogSectionProps = { settings: GlobalSettings systemPrefersDark: boolean @@ -45,7 +89,13 @@ export function TerminalThemeCatalogSection({ preferredTarget, advancedContent }: TerminalThemeCatalogSectionProps): React.JSX.Element { - const [target, setTarget] = useState(preferredTarget ?? 'dark') + const [target, setTargetState] = useState(() => + getInitialTerminalThemeTarget(settings, systemPrefersDark, preferredTarget) + ) + const setTarget = (nextTarget: TerminalThemeTarget): void => { + rememberTerminalThemeTarget(nextTarget) + setTargetState(nextTarget) + } const themeOptions = getAvailableTerminalThemeOptions(settings) const isLightTarget = target === 'light' const matchDarkMode = !settings.terminalUseSeparateLightTheme @@ -200,11 +250,12 @@ export function TerminalThemeCatalogSection({ themeOptions={themeOptions} query={themeSearch} onQueryChange={setThemeSearch} - onSelectTheme={(theme) => + onSelectTheme={(theme) => { + rememberTerminalThemeTarget(target) updateSettings( isLightTarget ? { terminalThemeLight: theme } : { terminalThemeDark: theme } ) - } + }} importedHighlightSignal={importedHighlightSignal} /> diff --git a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx index 70fa7e3eb7f..8633b735a96 100644 --- a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx +++ b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx @@ -14,6 +14,7 @@ import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' import { useSidebarHostScopeOptions } from '../sidebar/use-sidebar-host-scope-options' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { buildHostScopeChoices, CLIENT_DEFAULT_SCOPE, @@ -197,7 +198,9 @@ export function WorkspaceDirectorySetting({ }} onBlur={handleBlur} onKeyDown={(e) => { - if (isComposingKeyboardEvent(e)) { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(e)) { return } if (e.key === 'Enter') { @@ -270,8 +273,3 @@ export function WorkspaceDirectorySetting({ ) } - -function isComposingKeyboardEvent(event: React.KeyboardEvent): boolean { - const nativeEvent = event.nativeEvent - return nativeEvent.isComposing || nativeEvent.keyCode === 229 -} diff --git a/src/renderer/src/components/settings/general-editor-search.ts b/src/renderer/src/components/settings/general-editor-search.ts index 0a13d7c5dd8..8f93eea9544 100644 --- a/src/renderer/src/components/settings/general-editor-search.ts +++ b/src/renderer/src/components/settings/general-editor-search.ts @@ -79,6 +79,29 @@ export const getGeneralEditorSearchEntries = createLocalizedCatalog(() => [ ...translateSearchKeyword('auto.components.settings.general.search.a0014961ae', 'scroll') ] }, + { + title: translate( + 'auto.components.settings.general.search.d2d2d929c0', + 'Rich Markdown Spellcheck' + ), + description: translate( + 'auto.components.settings.general.search.4497e2e2bb', + 'Show browser spelling underlines and suggestions while editing rich Markdown.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.general.search.f96cdaf37d', 'spellcheck'), + ...translateSearchKeyword( + 'auto.components.settings.general.search.a51d23f4a1', + 'spell check' + ), + ...translateSearchKeyword('auto.components.settings.general.search.641358460a', 'spelling'), + ...translateSearchKeyword('auto.components.settings.general.search.d05f629d2c', 'markdown'), + ...translateSearchKeyword( + 'auto.components.settings.general.search.d755962089', + 'red underline' + ) + ] + }, { title: translate('auto.components.settings.general.search.128bc09325', 'Markdown Review Notes'), description: translate( diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 9fe63453d67..1c5f27c629b 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -193,22 +193,26 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { onGitRepoReady: completeGitRepoAdd, setAddProjectBusyLabel }) - const { handleImportNestedRepos, resetNestedImportFlow, trackNestedBackAction } = - useAddRepoNestedImportFlow({ - nestedAttemptId, - nestedScan, - nestedSelectedPaths, - nestedRuntimeKind, - nestedConnectionId, - nestedGroupName, - nestedImportScanId, - activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, - fetchWorktrees, - importNestedRepos, - getNestedRepoRuntimeKind, - onGitRepoReady: completeGitRepoAdd, - setIsAdding - }) + const { + handleImportNestedRepos, + handleOpenNestedRootFolder, + resetNestedImportFlow, + trackNestedBackAction + } = useAddRepoNestedImportFlow({ + nestedAttemptId, + nestedScan, + nestedSelectedPaths, + nestedRuntimeKind, + nestedConnectionId, + nestedGroupName, + nestedImportScanId, + activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, + fetchWorktrees, + importNestedRepos, + getNestedRepoRuntimeKind, + onGitRepoReady: completeGitRepoAdd, + setIsAdding + }) const resetState = useCallback(() => { // Why: kill the git clone process if one is running, so backing out @@ -381,6 +385,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { onNestedGroupNameChange={setNestedGroupName} onNestedSelectedPathsChange={setNestedSelectedPaths} onImportNestedRepos={(mode) => void handleImportNestedRepos(mode)} + onOpenNestedRootFolder={() => void handleOpenNestedRootFolder()} onCreateNameChange={(value) => { setCreateName(value) setCreateError(null) diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index 26ec195d440..b70c299be36 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx @@ -79,6 +79,7 @@ function renderStepContent(overrides: Partial): string { onNestedGroupNameChange: vi.fn(), onNestedSelectedPathsChange: vi.fn(), onImportNestedRepos: vi.fn(), + onOpenNestedRootFolder: vi.fn(), onCreateNameChange: vi.fn(), onCreateParentChange: vi.fn(), onPickCreateParent: vi.fn(), @@ -120,6 +121,15 @@ describe('AddRepoDialogStepContent nested imports', () => { expect(html).not.toContain('>Import') }) + it('offers opening the parent folder when nested import selection is empty', () => { + const html = renderStepContent({ nestedSelectedPaths: new Set() }) + + expect(html).toContain('No repositories are selected') + expect(html).toContain('Open as Folder') + expect(html).toMatch(/]*disabled=""[^>]*>No, import separately<\/button>/) + expect(html).toMatch(/]*disabled=""[^>]*>Yes, import as group<\/button>/) + }) + it('offers host browsing for remote create project locations', () => { const html = renderStepContent({ step: 'create', diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx index 655a21de509..ee05b70b433 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx @@ -72,6 +72,7 @@ type AddRepoDialogStepContentProps = { onNestedGroupNameChange: (name: string) => void onNestedSelectedPathsChange: Dispatch>> onImportNestedRepos: (mode: 'group' | 'separate') => void + onOpenNestedRootFolder: () => void onCreateNameChange: (name: string) => void onCreateParentChange: (parent: string) => void onPickCreateParent: () => void @@ -140,6 +141,7 @@ export function AddRepoDialogStepContent({ onNestedGroupNameChange, onNestedSelectedPathsChange, onImportNestedRepos, + onOpenNestedRootFolder, onCreateNameChange, onCreateParentChange, onPickCreateParent, @@ -237,6 +239,7 @@ export function AddRepoDialogStepContent({ onGroupNameChange={onNestedGroupNameChange} onSelectedPathsChange={onNestedSelectedPathsChange} onImport={onImportNestedRepos} + onOpenAsFolder={onOpenNestedRootFolder} onStopScan={onStopNestedScan} /> ) diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx index ba9022ca742..ff243a3a5cc 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx @@ -41,6 +41,7 @@ function renderStepMarkup( onGroupNameChange={vi.fn()} onSelectedPathsChange={vi.fn()} onImport={vi.fn()} + onOpenAsFolder={vi.fn()} onStopScan={vi.fn()} {...overrides} /> @@ -122,6 +123,7 @@ describe('AddRepoNestedImportStep', () => { onGroupNameChange={vi.fn()} onSelectedPathsChange={vi.fn()} onImport={onImport} + onOpenAsFolder={vi.fn()} onStopScan={vi.fn()} /> @@ -162,6 +164,7 @@ describe('AddRepoNestedImportStep', () => { onImport(mode) setIsAdding(true) }} + onOpenAsFolder={vi.fn()} onStopScan={vi.fn()} /> @@ -181,4 +184,44 @@ describe('AddRepoNestedImportStep', () => { expect(findButton(host, 'Yes, import as group').querySelector('.animate-spin')).not.toBeNull() expect(findButton(host, 'No, import separately').querySelector('.animate-spin')).toBeNull() }) + + it('offers opening the parent folder when no repositories are selected', () => { + const onOpenAsFolder = vi.fn() + const host = document.createElement('div') + container = host + document.body.appendChild(host) + root = createRoot(host) + + act(() => { + root?.render( + + + + + + ) + }) + + expect(host.textContent).toContain('No repositories are selected') + expect(findButton(host, 'No, import separately').disabled).toBe(true) + expect(findButton(host, 'Yes, import as group').disabled).toBe(true) + expect(findButton(host, 'Open as Folder').disabled).toBe(false) + + act(() => { + findButton(host, 'Open as Folder').click() + }) + + expect(onOpenAsFolder).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx index ac7db3b2958..ef41edbf1a1 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx @@ -20,6 +20,7 @@ type AddRepoNestedImportStepProps = { onGroupNameChange: (value: string) => void onSelectedPathsChange: Dispatch>> onImport: (mode: 'group' | 'separate') => void + onOpenAsFolder: () => void onStopScan: () => void } @@ -32,11 +33,16 @@ export function AddRepoNestedImportStep({ onGroupNameChange, onSelectedPathsChange, onImport, + onOpenAsFolder, onStopScan }: AddRepoNestedImportStepProps): React.JSX.Element { const folderName = getRuntimePathBasename(scan.selectedPath) || scan.selectedPath const groupNameInputId = useId() - const [pendingImportMode, setPendingImportMode] = useState<'group' | 'separate' | null>(null) + const [pendingImportMode, setPendingImportMode] = useState< + 'folder' | 'group' | 'separate' | null + >(null) + const noRepositoriesSelected = selectedPaths.size === 0 + const showOpenAsFolderSpinner = isAdding && pendingImportMode === 'folder' const showSeparateSpinner = isAdding && pendingImportMode === 'separate' const showGroupSpinner = isAdding && pendingImportMode === 'group' @@ -50,6 +56,10 @@ export function AddRepoNestedImportStep({ setPendingImportMode(mode) onImport(mode) } + const handleOpenAsFolder = (): void => { + setPendingImportMode('folder') + onOpenAsFolder() + } const repoCountLabel = scan.repos.length === 1 ? translate('auto.components.sidebar.AddRepoNestedImportStep.8401a7a0d0', '1 repository') @@ -137,10 +147,31 @@ export function AddRepoNestedImportStep({ placeholder={folderName} /> + {noRepositoriesSelected ? ( +

+ {translate( + 'auto.components.sidebar.AddRepoNestedImportStep.6149d5203f', + 'No repositories are selected. Open the parent folder instead to use editor, terminal, and search without Git features.' + )} +

+ ) : null}
+ {noRepositoriesSelected ? ( + + ) : null}