mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/codex-icon-running-agent
# Conflicts: # src/renderer/src/components/terminal-pane/pty-connection.ts
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.3m">
|
||||
<title>downloads: 3.3m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.4m">
|
||||
<title>downloads: 3.4m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.3m</text>
|
||||
<text x="93.5" y="14">3.3m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.4m</text>
|
||||
<text x="93.5" y="14">3.4m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 434 KiB |
@@ -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)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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<MobileTerminalLinkOpenMode>('orca-browser')
|
||||
const [liveInputCapture, setLiveInputCapture] = useState('')
|
||||
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const liveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const defaultedLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const {
|
||||
clearTerminalLiveInputDefault,
|
||||
defaultTerminalHandlesToLiveInput,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
pruneTerminalHandlesFromLiveInput,
|
||||
toggleTerminalLiveInput
|
||||
} = useTerminalLiveInputModePreference({ hostId, worktreeId })
|
||||
const [activeHandle, setActiveHandle] = useState<string | null>(null)
|
||||
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
|
||||
const activeSessionTabIdRef = useRef<string | null>(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<string>) => {
|
||||
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 => {
|
||||
|
||||
@@ -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<typeof useTerminalLiveInputModePreference>
|
||||
readonly unmount: () => void
|
||||
}
|
||||
|
||||
type Deferred<T> = {
|
||||
readonly promise: Promise<T>
|
||||
readonly resolve: (value: T) => void
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve: ((value: T) => void) | null = null
|
||||
const promise = new Promise<T>((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<typeof useTerminalLiveInputModePreference> | 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<DisabledTerminalLiveInputHandlesPreference>()
|
||||
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<DisabledTerminalLiveInputHandlesPreference>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const liveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const defaultedLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const disabledLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const disabledLiveInputHydratedRef = useRef(false)
|
||||
const pendingDisabledLiveInputHydrationEditsRef = useRef<Map<string, boolean>>(new Map())
|
||||
const pendingLiveInputDefaultHandlesRef = useRef<Set<string>>(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<string>) => {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string>
|
||||
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<DisabledTerminalLiveInputHandlesPreference> {
|
||||
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<Set<string>> {
|
||||
const preference = await readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId)
|
||||
return preference.handles
|
||||
}
|
||||
|
||||
export async function saveDisabledTerminalLiveInputHandles(
|
||||
hostId: string,
|
||||
worktreeId: string,
|
||||
handles: ReadonlySet<string>
|
||||
): Promise<void> {
|
||||
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
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -118,6 +118,48 @@ export function defaultTerminalLiveInputHandles(
|
||||
}
|
||||
}
|
||||
|
||||
export function filterTerminalLiveInputDefaultCandidates(
|
||||
terminalHandles: readonly string[],
|
||||
disabledHandles: ReadonlySet<string>
|
||||
): string[] {
|
||||
return terminalHandles.filter((handle) => !disabledHandles.has(handle))
|
||||
}
|
||||
|
||||
export function applyDisabledTerminalLiveInputHandles(
|
||||
enabledHandles: ReadonlySet<string>,
|
||||
defaultedHandles: ReadonlySet<string>,
|
||||
disabledHandles: ReadonlySet<string>
|
||||
): TerminalLiveInputDefaultResult {
|
||||
let nextEnabledHandles: Set<string> | null = null
|
||||
let nextDefaultedHandles: Set<string> | 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<string>,
|
||||
defaultedHandles: ReadonlySet<string>,
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '<span></span>',
|
||||
footerTemplate: '<span></span>'
|
||||
})
|
||||
).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: '<span></span>',
|
||||
footerTemplate: '<span></span>'
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>): 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<typeof setTimeout>
|
||||
}
|
||||
|
||||
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<string, PdfStream>()
|
||||
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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<typeof setTimeout> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>) => void
|
||||
const focusPromise = new Promise<Record<string, unknown>>((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<string, unknown>[] = []
|
||||
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<string, unknown>) => void = () => {}
|
||||
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const [method] = args as [string]
|
||||
if (method === 'DOM.focus') {
|
||||
return new Promise<Record<string, unknown>>((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<string, unknown>) => 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<Record<string, unknown>>((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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>,
|
||||
sessionId?: string
|
||||
) => Promise<Record<string, unknown>>
|
||||
>
|
||||
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<Buffer>>
|
||||
reload: Mock<() => void>
|
||||
reloadIgnoringCache: Mock<() => void>
|
||||
getTitle: Mock<() => string>
|
||||
getURL: Mock<() => string>
|
||||
}
|
||||
listeners: Map<string, DebuggerListener[]>
|
||||
destroy: () => void
|
||||
emit: (event: string, ...args: unknown[]) => void
|
||||
}
|
||||
|
||||
export function createMockWebContents(): MockWebContents {
|
||||
const listeners = new Map<string, DebuggerListener[]>()
|
||||
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<string, unknown>, _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, unknown>?, string?]
|
||||
|
||||
export function connect(endpoint: string): Promise<WebSocket> {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(endpoint)
|
||||
ws.on('open', () => resolve(ws))
|
||||
})
|
||||
}
|
||||
|
||||
export function sendAndReceive(
|
||||
ws: WebSocket,
|
||||
msg: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
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])
|
||||
}
|
||||
@@ -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<string, DebuggerListener[]>()
|
||||
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<typeof createMockWebContents>
|
||||
let mock: MockWebContents
|
||||
let proxy: CdpWsProxy
|
||||
let endpoint: string
|
||||
|
||||
@@ -72,22 +30,14 @@ describe('CdpWsProxy', () => {
|
||||
await proxy.stop()
|
||||
})
|
||||
|
||||
function connect(): Promise<WebSocket> {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(endpoint)
|
||||
ws.on('open', () => resolve(ws))
|
||||
})
|
||||
function expectPdfStreamHandle(response: Record<string, unknown>): string {
|
||||
const result = response.result as Record<string, unknown>
|
||||
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<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
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<string, unknown>[] = []
|
||||
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<string, unknown>[] = []
|
||||
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<Record<string, unknown>>((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<Record<string, unknown>>((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<ArrayBuffer>) => void = () => {}
|
||||
mock.webContents.printToPDF.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Buffer<ArrayBuffer>>((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))
|
||||
|
||||
@@ -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<Record<string, unknown> | 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<string, unknown>,
|
||||
sessionId?: string
|
||||
): Promise<unknown> {
|
||||
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<string, unknown>,
|
||||
msgSessionId?: string
|
||||
): Promise<void> {
|
||||
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<string, unknown>,
|
||||
msgSessionId?: string
|
||||
): Promise<void> {
|
||||
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, unknown>): string | null {
|
||||
return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null
|
||||
}
|
||||
|
||||
private async primePageLifecycle(sessionId?: string): Promise<void> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
const priming = (async (): Promise<void> => {
|
||||
// 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<void>((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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
effectiveSessionId?: string
|
||||
): Promise<Record<string, unknown> | 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<string, unknown>,
|
||||
effectiveSessionId?: string
|
||||
): Promise<void> {
|
||||
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<string, unknown>
|
||||
): Promise<void> {
|
||||
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<string, unknown>
|
||||
): 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<string, unknown>
|
||||
): void {
|
||||
this.pdfStreams.close(params)
|
||||
this.sendResult(clientId, {}, client)
|
||||
}
|
||||
|
||||
private handleScreenshot(
|
||||
|
||||
@@ -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("'", `'"'"'`)}'`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<LinuxBareOrcaDispatcherResult> {
|
||||
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<boolean> {
|
||||
try {
|
||||
return (await readFile(dispatcherPath, 'utf8')).includes(DISPATCHER_MARKER)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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<string>
|
||||
lastFullCheckpointAt: Map<string, number>
|
||||
}
|
||||
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<unknown> }
|
||||
}
|
||||
).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<unknown> }
|
||||
}
|
||||
).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<string>
|
||||
lastFullCheckpointAt: Map<string, number>
|
||||
}
|
||||
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<unknown> }
|
||||
}
|
||||
).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))
|
||||
|
||||
@@ -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<PtyProcessInfo[]> {
|
||||
await this.ensureConnected()
|
||||
const result = await this.client.request<ListSessionsResult>('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 } : {})
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PtyProcessInfo[]> {
|
||||
// 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()))
|
||||
|
||||
@@ -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<void>
|
||||
@@ -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<PtyProcessInfo[]> {
|
||||
const results = await Promise.all(
|
||||
this.allProviders().map((provider) => provider.listProcesses())
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -324,6 +324,7 @@ export type SessionInfo = {
|
||||
state: SessionState
|
||||
shellState: ShellReadyState
|
||||
isAlive: boolean
|
||||
terminalHandle?: string
|
||||
pid: number | null
|
||||
cwd: string | null
|
||||
cols: number
|
||||
|
||||
@@ -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 [<options>]\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 [<Optionen>]\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'))
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, pty.IPty>()
|
||||
const ptyShellName = new Map<string, string>()
|
||||
const ptyAgentForegroundContextPaths = new Map<string, string[]>()
|
||||
const ptyTerminalHandle = new Map<string, string>()
|
||||
// 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<PtyProcessInfo[]> {
|
||||
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) } : {})
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PtyProcessInfo[]> {
|
||||
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)
|
||||
}))
|
||||
|
||||
@@ -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<PtySpawnResult>
|
||||
attach(id: string): Promise<void>
|
||||
@@ -128,7 +136,7 @@ export type IPtyProvider = {
|
||||
getForegroundProcess(id: string): Promise<string | null>
|
||||
serialize(ids: string[]): Promise<string>
|
||||
revive(state: string): Promise<void>
|
||||
listProcesses(): Promise<{ id: string; cwd: string; title: string }[]>
|
||||
listProcesses(): Promise<PtyProcessInfo[]>
|
||||
getDefaultShell(): Promise<string>
|
||||
getProfiles(): Promise<{ name: string; path: string }[]>
|
||||
onData(callback: (payload: { id: string; data: string }) => void): () => void
|
||||
|
||||
@@ -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-'))
|
||||
|
||||
@@ -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<T>(
|
||||
location: ManagedCredentialsLocation,
|
||||
credentialsJson: string,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
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<string | null> {
|
||||
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<ProviderRateLimits | null> {
|
||||
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<ProviderRateLimits> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<ProviderRateLimits>()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<T>() {
|
||||
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 })
|
||||
|
||||
@@ -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 <webview>; 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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void>; resolve: () => void } {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((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<void> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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<void>
|
||||
}
|
||||
const headless = (
|
||||
runtime as unknown as { headlessTerminals: Map<string, HeadlessStateForTest> }
|
||||
).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<void> => {
|
||||
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<string, string[]>()
|
||||
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<void>((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<string | null>()
|
||||
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<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp')
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
foregroundProcess.resolve('omp')
|
||||
await new Promise<void>((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<string | null>()
|
||||
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<void>((resolve) => setImmediate(resolve))
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
foregroundProcess.resolve('omp')
|
||||
await new Promise<void>((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<string | null>()
|
||||
const freshForegroundProcess = deferred<string | null>()
|
||||
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<void>((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<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
staleForegroundProcess.resolve(null)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
freshForegroundProcess.resolve('omp')
|
||||
await new Promise<void>((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<string | null>()
|
||||
const freshForegroundProcess = deferred<string | null>()
|
||||
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<boolean>
|
||||
}
|
||||
).refreshPtyForegroundAgentFromController('pty-typed-omp')
|
||||
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
staleForegroundProcess.resolve(null)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
freshForegroundProcess.resolve('omp')
|
||||
await new Promise<void>((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<void>((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'
|
||||
|
||||
@@ -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<boolean>
|
||||
startedAfterTitleObservation: number
|
||||
requestedAfterTitleObservation: number
|
||||
}
|
||||
|
||||
function copySleepingAgentLaunchConfig(
|
||||
config: SleepingAgentLaunchConfig
|
||||
): SleepingAgentLaunchConfig {
|
||||
@@ -1037,7 +1044,7 @@ type RuntimePtyController = {
|
||||
hasChildProcesses?(ptyId: string): Promise<boolean>
|
||||
clearBuffer?(ptyId: string): Promise<void>
|
||||
resize?(ptyId: string, cols: number, rows: number): boolean
|
||||
listProcesses?(): Promise<{ id: string; cwd: string; title: string }[]>
|
||||
listProcesses?(): Promise<PtyProcessInfo[]>
|
||||
serializeBuffer?(
|
||||
ptyId: string,
|
||||
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
|
||||
@@ -1925,7 +1932,8 @@ export class OrcaRuntimeService {
|
||||
private resolvedWorktreeGeneration = 0
|
||||
private cloneInFlightByPath = new Map<string, Promise<void>>()
|
||||
private agentDetector: AgentDetector | null = null
|
||||
private ptyForegroundAgentRefreshes = new Map<string, Promise<void>>()
|
||||
private ptyForegroundAgentRefreshes = new Map<string, PtyForegroundAgentRefresh>()
|
||||
private ptyDelayedForegroundSnapshotTitleObservations = new Map<string, number>()
|
||||
private _orchestrationDb: OrchestrationDb | null = null
|
||||
private messageWaitersByHandle = new Map<string, Set<MessageWaiter>>()
|
||||
// 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<boolean> | 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<boolean> | undefined {
|
||||
if (!this.ptyForegroundAgentRefreshes.has(ptyId)) {
|
||||
return undefined
|
||||
}
|
||||
return this.refreshPtyForegroundAgentFromController(ptyId, {
|
||||
afterTitleObservation: titleObservedAt
|
||||
})
|
||||
}
|
||||
|
||||
private delayPtyBackedMobileSnapshotForForegroundAgent(
|
||||
ptyId: string,
|
||||
titleObservedAt: number,
|
||||
foregroundRefresh: Promise<boolean>
|
||||
): 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<void> {
|
||||
private refreshPtyForegroundAgentFromController(
|
||||
ptyId: string,
|
||||
options: { afterTitleObservation?: number } = {}
|
||||
): Promise<boolean> {
|
||||
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<boolean> => {
|
||||
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<void> {
|
||||
private async loadPtyForegroundAgentFromController(ptyId: string): Promise<boolean> {
|
||||
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)
|
||||
|
||||
@@ -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: (
|
||||
|
||||
+16
-4
@@ -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)
|
||||
|
||||
@@ -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 [<options>]\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 [<Optionen>]\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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<PtyProcessSummary[]> {
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -362,8 +362,8 @@ export default function Landing(): React.JSX.Element {
|
||||
onClick={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })}
|
||||
>
|
||||
<GitBranchPlus className="size-3.5" />
|
||||
{translate('auto.components.Landing.76a95f7f47', 'Create')}
|
||||
{createTargetLabel}
|
||||
{translate('auto.components.Landing.76a95f7f47', 'Create')}{' '}
|
||||
{createTargetLabel.toLowerCase()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<Editor | null>(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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 <webview> 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 <webview> 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
|
||||
|
||||
@@ -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<string, Electron.WebviewTag>()
|
||||
}))
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 <webview> 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 }
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement | null>(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
|
||||
|
||||
@@ -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<T>(current: T): MutableRefObject<T> {
|
||||
return { current }
|
||||
}
|
||||
|
||||
function stateSetter<T>(): Dispatch<SetStateAction<T>> {
|
||||
return vi.fn() as Dispatch<SetStateAction<T>>
|
||||
}
|
||||
|
||||
function getSpellcheckAttribute(config: ReturnType<typeof createRichMarkdownEditorConfig>): string {
|
||||
const attributes = config.editorProps?.attributes
|
||||
return typeof attributes === 'function'
|
||||
? attributes({} as never).spellcheck
|
||||
: (attributes?.spellcheck ?? '')
|
||||
}
|
||||
|
||||
function createConfigParams(overrides: Partial<EditorConfigParams> = {}): 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<HTMLDivElement | null>(null),
|
||||
editorRef: ref<Editor | null>(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<boolean>(),
|
||||
setLinkBubble: stateSetter<LinkBubbleState | null>(),
|
||||
setSelectedCommandIndex: stateSetter<number>(),
|
||||
setSelectedDocLinkIndex: stateSetter<number>(),
|
||||
setSlashMenu: stateSetter<SlashMenuState | null>(),
|
||||
setDocLinkMenu: stateSetter<DocLinkMenuState | null>(),
|
||||
...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')
|
||||
})
|
||||
})
|
||||
@@ -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<HTMLDivElement | null>
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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<ComposerTab>('write')
|
||||
const [linkBubble, setLinkBubble] = useState<LinkBubbleState | null>(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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record<IosChannel, InstallCopy> = {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<typeof getAiVaultResumeWorkspaceTargetStatus>
|
||||
): 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') {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<RichMarkdownSpellcheckSetting settings={settings} updateSettings={updateSettings} />
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.GeneralEditorSettingsSection.4edc104f0f',
|
||||
|
||||
@@ -95,6 +95,13 @@ describe('GeneralPane search entries', () => {
|
||||
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 })
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<GlobalSettings, 'richMarkdownSpellcheckEnabled'>
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
}
|
||||
|
||||
export function RichMarkdownSpellcheckSetting({
|
||||
settings,
|
||||
updateSettings
|
||||
}: RichMarkdownSpellcheckSettingProps): React.JSX.Element {
|
||||
const enabled = settings.richMarkdownSpellcheckEnabled ?? true
|
||||
|
||||
return (
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.GeneralEditorSettingsSection.b82f86d7d2',
|
||||
'Rich Markdown Spellcheck'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.GeneralEditorSettingsSection.5195f0b9ef',
|
||||
'Show browser spelling underlines and suggestions while editing rich Markdown.'
|
||||
)}
|
||||
keywords={['spellcheck', 'spell check', 'spelling', 'markdown', 'red underline']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.GeneralEditorSettingsSection.b82f86d7d2',
|
||||
'Rich Markdown Spellcheck'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.GeneralEditorSettingsSection.5195f0b9ef',
|
||||
'Show browser spelling underlines and suggestions while editing rich Markdown.'
|
||||
)}
|
||||
checked={enabled}
|
||||
onChange={() => updateSettings({ richMarkdownSpellcheckEnabled: !enabled })}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
@@ -8,12 +8,15 @@ vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
||||
return {
|
||||
...actual,
|
||||
useState: <T>(initial: T) => [
|
||||
themeTarget ?? initial,
|
||||
(value: T) => {
|
||||
themeTarget = value as 'dark' | 'light'
|
||||
}
|
||||
]
|
||||
useState: <T>(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> = {}): 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')
|
||||
|
||||
@@ -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<TerminalThemeTarget>(preferredTarget ?? 'dark')
|
||||
const [target, setTargetState] = useState<TerminalThemeTarget>(() =>
|
||||
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}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
@@ -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({
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
|
||||
function isComposingKeyboardEvent(event: React.KeyboardEvent<HTMLInputElement>): boolean {
|
||||
const nativeEvent = event.nativeEvent
|
||||
return nativeEvent.isComposing || nativeEvent.keyCode === 229
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -79,6 +79,7 @@ function renderStepContent(overrides: Partial<StepContentProps>): 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</button>')
|
||||
})
|
||||
|
||||
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(/<button[^>]*disabled=""[^>]*>No, import separately<\/button>/)
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Yes, import as group<\/button>/)
|
||||
})
|
||||
|
||||
it('offers host browsing for remote create project locations', () => {
|
||||
const html = renderStepContent({
|
||||
step: 'create',
|
||||
|
||||
@@ -72,6 +72,7 @@ type AddRepoDialogStepContentProps = {
|
||||
onNestedGroupNameChange: (name: string) => void
|
||||
onNestedSelectedPathsChange: Dispatch<SetStateAction<Set<string>>>
|
||||
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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
</Dialog>
|
||||
@@ -162,6 +164,7 @@ describe('AddRepoNestedImportStep', () => {
|
||||
onImport(mode)
|
||||
setIsAdding(true)
|
||||
}}
|
||||
onOpenAsFolder={vi.fn()}
|
||||
onStopScan={vi.fn()}
|
||||
/>
|
||||
</Dialog>
|
||||
@@ -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(
|
||||
<TooltipProvider>
|
||||
<Dialog open>
|
||||
<AddRepoNestedImportStep
|
||||
scan={scan}
|
||||
groupName=""
|
||||
selectedPaths={new Set()}
|
||||
isAdding={false}
|
||||
scanInProgress={false}
|
||||
onGroupNameChange={vi.fn()}
|
||||
onSelectedPathsChange={vi.fn()}
|
||||
onImport={vi.fn()}
|
||||
onOpenAsFolder={onOpenAsFolder}
|
||||
onStopScan={vi.fn()}
|
||||
/>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ type AddRepoNestedImportStepProps = {
|
||||
onGroupNameChange: (value: string) => void
|
||||
onSelectedPathsChange: Dispatch<SetStateAction<Set<string>>>
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
{noRepositoriesSelected ? (
|
||||
<p className="shrink-0 text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.6149d5203f',
|
||||
'No repositories are selected. Open the parent folder instead to use editor, terminal, and search without Git features.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
{noRepositoriesSelected ? (
|
||||
<Button
|
||||
onClick={handleOpenAsFolder}
|
||||
disabled={isAdding || scanInProgress}
|
||||
variant="secondary"
|
||||
>
|
||||
{showOpenAsFolderSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRepoNestedImportStep.e52454b7f6',
|
||||
'Open as Folder'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={() => handleImport('separate')}
|
||||
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
|
||||
disabled={isAdding || scanInProgress || noRepositoriesSelected}
|
||||
variant="outline"
|
||||
>
|
||||
{showSeparateSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
@@ -151,7 +182,7 @@ export function AddRepoNestedImportStep({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleImport('group')}
|
||||
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
|
||||
disabled={isAdding || scanInProgress || noRepositoriesSelected}
|
||||
>
|
||||
{showGroupSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{translate(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SidebarSettingsHelpMenu } from './SidebarSettingsHelpMenu'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -20,6 +23,7 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
let updateStatus = { state: 'idle' } as const
|
||||
const roots: Root[] = []
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
@@ -96,9 +100,48 @@ vi.mock('./SidebarFeedbackDialog', () => ({
|
||||
SidebarFeedbackDialog: () => <div data-testid="feedback-dialog" />
|
||||
}))
|
||||
|
||||
function installWindowApi(): void {
|
||||
Object.assign(window, {
|
||||
api: {
|
||||
app: {
|
||||
restart: mocks.appRestart
|
||||
},
|
||||
shell: {
|
||||
openUrl: mocks.shellOpenUrl
|
||||
},
|
||||
updater: {
|
||||
check: mocks.updaterCheck
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function renderMenu(): Promise<HTMLDivElement> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
|
||||
await act(async () => {
|
||||
root.render(<SidebarSettingsHelpMenu />)
|
||||
})
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
function findMenuItem(container: HTMLElement, label: string): HTMLButtonElement {
|
||||
const button = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('[data-testid="menu-item"]')
|
||||
).find((element) => element.textContent?.includes(label))
|
||||
expect(button).toBeDefined()
|
||||
return button as HTMLButtonElement
|
||||
}
|
||||
|
||||
describe('SidebarSettingsHelpMenu', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.clearAllMocks()
|
||||
installWindowApi()
|
||||
mocks.useShortcutKeyDetails.mockReturnValue({ keys: ['⌘', ','], doubleTap: false })
|
||||
updateStatus = { state: 'idle' }
|
||||
mocks.setupProgress = {
|
||||
@@ -109,6 +152,13 @@ describe('SidebarSettingsHelpMenu', () => {
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
roots.splice(0).forEach((root) => {
|
||||
act(() => root.unmount())
|
||||
})
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('renders the help button with correct aria-label', () => {
|
||||
const html = renderToStaticMarkup(<SidebarSettingsHelpMenu />)
|
||||
expect(html).toContain('Help')
|
||||
@@ -177,6 +227,19 @@ describe('SidebarSettingsHelpMenu', () => {
|
||||
it('renders Discord link', () => {
|
||||
const html = renderToStaticMarkup(<SidebarSettingsHelpMenu />)
|
||||
expect(html).toContain('Discord')
|
||||
expect(html).toContain('viewBox="0 0 20 20"')
|
||||
expect(html).toContain('M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335')
|
||||
})
|
||||
|
||||
it('opens Discord invite through the shell bridge', async () => {
|
||||
const container = await renderMenu()
|
||||
const discordButton = findMenuItem(container, 'Discord')
|
||||
|
||||
await act(async () => {
|
||||
discordButton.click()
|
||||
})
|
||||
|
||||
expect(mocks.shellOpenUrl).toHaveBeenCalledWith('https://discord.gg/fzjDKHxv8Q')
|
||||
})
|
||||
|
||||
it('renders X link', () => {
|
||||
|
||||
@@ -46,8 +46,8 @@ function openExternalUrl(url: string): void {
|
||||
|
||||
function DiscordIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="size-3.5 fill-current">
|
||||
<path d="M20.317 4.369A19.791 19.791 0 0 0 15.885 3c-.191.328-.403.77-.553 1.116a18.27 18.27 0 0 0-5.098 0A12.64 12.64 0 0 0 9.68 3a19.736 19.736 0 0 0-4.433 1.369C2.444 8.479 1.69 12.488 2.067 16.44a19.912 19.912 0 0 0 5.427 2.744c.438-.598.828-1.23 1.164-1.89a12.95 12.95 0 0 1-1.833-.877c.154-.113.305-.231.45-.352a14.294 14.294 0 0 0 12.45 0c.146.12.296.239.45.352-.585.34-1.2.634-1.835.878.337.659.727 1.29 1.165 1.888a19.84 19.84 0 0 0 5.43-2.744c.442-4.579-.755-8.551-3.932-12.07ZM9.955 14.005c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.418 2.157-2.418 1.211 0 2.176 1.095 2.157 2.418 0 1.334-.955 2.419-2.157 2.419Zm4.09 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.418 2.157-2.418 1.211 0 2.176 1.095 2.157 2.418 0 1.334-.946 2.419-2.157 2.419Z" />
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" className="size-3.5 fill-current">
|
||||
<path d="M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335C12.2932 3.64011 12.1388 3.95557 12.0013 4.27856C10.6732 4.07738 9.32261 4.07738 7.99451 4.27856C7.85694 3.9556 7.70257 3.64014 7.53203 3.3335C6.28441 3.54735 5.06981 3.92365 3.91889 4.45291C1.63401 7.85128 1.01462 11.1652 1.32431 14.4322C2.6624 15.426 4.16009 16.1819 5.7523 16.6668C6.11082 16.1821 6.42806 15.6678 6.70066 15.1295C6.18289 14.9351 5.68315 14.6953 5.20723 14.4128C5.33249 14.3215 5.45499 14.2274 5.57336 14.136C6.95819 14.7907 8.46965 15.1302 9.99997 15.1302C11.5303 15.1302 13.0418 14.7907 14.4266 14.136C14.5463 14.2343 14.6688 14.3284 14.7927 14.4128C14.3159 14.6957 13.8152 14.9361 13.2965 15.1309C13.5688 15.669 13.8861 16.1828 14.2449 16.6668C15.8385 16.1838 17.3373 15.4283 18.6756 14.4335C19.039 10.645 18.0549 7.36145 16.0742 4.45014ZM7.09294 12.423C6.22992 12.423 5.51693 11.6357 5.51693 10.6671C5.51693 9.69852 6.20514 8.90427 7.09019 8.90427C7.97524 8.90427 8.68272 9.69852 8.66758 10.6671C8.65244 11.6357 7.97248 12.423 7.09294 12.423ZM12.907 12.423C12.0426 12.423 11.3324 11.6357 11.3324 10.6671C11.3324 9.69852 12.0206 8.90427 12.907 8.90427C13.7934 8.90427 14.4954 9.69852 14.4803 10.6671C14.4651 11.6357 13.7865 12.423 12.907 12.423Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,8 @@ import {
|
||||
} from '@/lib/scroll-to-current-workspace-status'
|
||||
import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag'
|
||||
import { getSidebarOrderedRepoHeaderIdsByBucket } from './project-header-drop'
|
||||
import { useProjectGroupHeaderDrag } from './project-group-header-drag'
|
||||
import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from './project-group-header-drop'
|
||||
import {
|
||||
buildManualOrderUpdatesForGroupDrop,
|
||||
buildManualOrderUpdatesForVisibleGroups,
|
||||
@@ -1377,7 +1379,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const suppressWorktreeClickUntilRef = useRef(0)
|
||||
const hasProjectGroups = projectGroups.length > 0
|
||||
const canReorderRepoHeaders = groupBy === 'repo' && projectOrderBy === 'manual'
|
||||
const canReorderProjectGroupHeaders = groupBy === 'repo' && hasProjectGroups
|
||||
const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup)
|
||||
const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)
|
||||
const lastVisibleRefreshKeyRef = useRef('')
|
||||
const reportVisibleGitHubPRRefreshCandidates = useAppStore(
|
||||
(s) => s.reportVisibleGitHubPRRefreshCandidates
|
||||
@@ -1399,6 +1403,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
),
|
||||
[projectGroups]
|
||||
)
|
||||
const projectGroupByIdForHeaderDrag = useMemo(
|
||||
() => new Map(projectGroups.map((group) => [group.id, group])),
|
||||
[projectGroups]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
@@ -1567,6 +1575,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
),
|
||||
[rows]
|
||||
)
|
||||
const sidebarProjectGroupHeaderIdsByBucket = useMemo(
|
||||
() =>
|
||||
getSidebarOrderedProjectGroupHeaderIdsByBucket(
|
||||
rows.filter((row): row is Row => row.type !== 'host-header'),
|
||||
projectGroupByIdForHeaderDrag
|
||||
),
|
||||
[projectGroupByIdForHeaderDrag, rows]
|
||||
)
|
||||
const repoHeaderIndexByRepoId = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const repoIds of sidebarRepoHeaderIdsByBucket.values()) {
|
||||
@@ -1585,12 +1601,43 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
}
|
||||
return map
|
||||
}, [sidebarRepoHeaderIdsByBucket])
|
||||
const projectGroupHeaderIndexByGroupId = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const groupIds of sidebarProjectGroupHeaderIdsByBucket.values()) {
|
||||
groupIds.forEach((groupId, index) => {
|
||||
map.set(groupId, index)
|
||||
})
|
||||
}
|
||||
return map
|
||||
}, [sidebarProjectGroupHeaderIdsByBucket])
|
||||
const projectGroupHeaderBucketByGroupId = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const [bucketKey, groupIds] of sidebarProjectGroupHeaderIdsByBucket) {
|
||||
for (const groupId of groupIds) {
|
||||
map.set(groupId, bucketKey)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [sidebarProjectGroupHeaderIdsByBucket])
|
||||
const commitProjectGroupOrder = useCallback(
|
||||
(repoId: string, projectGroupId: string | null, order: number) => {
|
||||
void moveProjectToGroup(repoId, projectGroupId, order)
|
||||
},
|
||||
[moveProjectToGroup]
|
||||
)
|
||||
const commitProjectGroupHeaderOrder = useCallback(
|
||||
(groupId: string, tabOrder: number) => {
|
||||
if (!Number.isFinite(tabOrder)) {
|
||||
return
|
||||
}
|
||||
const suppressUntil =
|
||||
window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS
|
||||
suppressMeasurementAdjustmentUntilRef.current = suppressUntil
|
||||
directScrollInputUntilRef.current = suppressUntil
|
||||
void updateProjectGroup(groupId, { tabOrder })
|
||||
},
|
||||
[updateProjectGroup]
|
||||
)
|
||||
// Drag is only meaningful when repo headers are using manual order. The
|
||||
// controller is still constructed for hook order stability when inert.
|
||||
const repoDrag = useRepoHeaderDrag({
|
||||
@@ -1602,6 +1649,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
onCommitProjectGroupOrder: commitProjectGroupOrder,
|
||||
getScrollContainer: () => scrollRef.current
|
||||
})
|
||||
const projectGroupDrag = useProjectGroupHeaderDrag({
|
||||
sidebarProjectGroupHeaderIdsByBucket,
|
||||
projectGroupById: projectGroupByIdForHeaderDrag,
|
||||
onCommitProjectGroupTabOrder: commitProjectGroupHeaderOrder,
|
||||
getScrollContainer: () => scrollRef.current
|
||||
})
|
||||
const [primaryActiveWorktreeRow, setPrimaryActiveWorktreeRow] = useState<{
|
||||
worktreeId: string
|
||||
rowKey: string
|
||||
@@ -3909,6 +3962,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
style={{ top: `${repoDrag.state.dropIndicatorY}px` }}
|
||||
/>
|
||||
) : null}
|
||||
{canReorderProjectGroupHeaders &&
|
||||
projectGroupDrag.state.draggingGroupId !== null &&
|
||||
projectGroupDrag.state.dropIndicatorY !== null ? (
|
||||
<div
|
||||
role="presentation"
|
||||
className="pointer-events-none absolute left-2 right-2 z-30 border-t border-dashed border-muted-foreground/70"
|
||||
style={{ top: `${projectGroupDrag.state.dropIndicatorY}px` }}
|
||||
/>
|
||||
) : null}
|
||||
{hostDrag.state.draggingHostId !== null && hostDrag.state.dropIndicatorY !== null ? (
|
||||
<div
|
||||
role="presentation"
|
||||
@@ -3998,6 +4060,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const isRepoHeader = groupBy === 'repo' && row.repo !== undefined
|
||||
const isProjectGroupHeader = groupBy === 'repo' && row.projectGroup !== undefined
|
||||
const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined
|
||||
const projectGroupIdForHeader =
|
||||
isProjectGroupHeader && !row.repo && typeof row.projectGroup?.id === 'string'
|
||||
? row.projectGroup.id
|
||||
: undefined
|
||||
const repoHeaderIndex =
|
||||
projectIdForHeader !== undefined
|
||||
? repoHeaderIndexByRepoId.get(projectIdForHeader)
|
||||
@@ -4006,6 +4072,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
projectIdForHeader !== undefined
|
||||
? repoHeaderBucketByRepoId.get(projectIdForHeader)
|
||||
: undefined
|
||||
const projectGroupHeaderIndex =
|
||||
projectGroupIdForHeader !== undefined
|
||||
? projectGroupHeaderIndexByGroupId.get(projectGroupIdForHeader)
|
||||
: undefined
|
||||
const projectGroupHeaderBucketKey =
|
||||
projectGroupIdForHeader !== undefined
|
||||
? projectGroupHeaderBucketByGroupId.get(projectGroupIdForHeader)
|
||||
: undefined
|
||||
const isDraggableRepoHeader = Boolean(
|
||||
canReorderRepoHeaders &&
|
||||
isRepoHeader &&
|
||||
@@ -4013,10 +4087,21 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
repoHeaderBucketKey &&
|
||||
(sidebarRepoHeaderIdsByBucket.get(repoHeaderBucketKey)?.length ?? 0) > 1
|
||||
)
|
||||
const isDraggableProjectGroupHeader = Boolean(
|
||||
canReorderProjectGroupHeaders &&
|
||||
projectGroupIdForHeader &&
|
||||
projectGroupHeaderBucketKey &&
|
||||
(sidebarProjectGroupHeaderIdsByBucket.get(projectGroupHeaderBucketKey)?.length ??
|
||||
0) > 1
|
||||
)
|
||||
const isDraggingThis =
|
||||
canReorderRepoHeaders &&
|
||||
repoDrag.state.draggingRepoId !== null &&
|
||||
repoDrag.state.draggingRepoId === projectIdForHeader
|
||||
const isDraggingThisProjectGroup =
|
||||
canReorderProjectGroupHeaders &&
|
||||
projectGroupDrag.state.draggingGroupId !== null &&
|
||||
projectGroupDrag.state.draggingGroupId === projectGroupIdForHeader
|
||||
const headerWorkspaceStatus =
|
||||
groupBy === 'workspace-status'
|
||||
? getWorkspaceStatusFromGroupKey(row.key, workspaceStatuses)
|
||||
@@ -4100,6 +4185,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
data-repo-header-id={projectIdForHeader}
|
||||
data-repo-header-index={repoHeaderIndex}
|
||||
data-repo-header-bucket={repoHeaderBucketKey}
|
||||
data-project-group-header-id={projectGroupIdForHeader}
|
||||
data-project-group-header-index={projectGroupHeaderIndex}
|
||||
data-project-group-header-bucket={projectGroupHeaderBucketKey}
|
||||
data-workspace-status-drop-target={headerWorkspaceStatus ? '' : undefined}
|
||||
data-workspace-status={headerWorkspaceStatus ?? undefined}
|
||||
data-workspace-pin-drop-target={isPinnedHeader ? '' : undefined}
|
||||
@@ -4108,7 +4196,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
'cursor-pointer',
|
||||
highlightedRevealRowKey === row.key &&
|
||||
'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50',
|
||||
isDraggingThis &&
|
||||
(isDraggingThis || isDraggingThisProjectGroup) &&
|
||||
'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]',
|
||||
headerWorkspaceStatus &&
|
||||
dragOverStatus === headerWorkspaceStatus &&
|
||||
@@ -4157,15 +4245,25 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
{row.icon ? (
|
||||
<div
|
||||
data-repo-header-drag-handle={isDraggableRepoHeader ? '' : undefined}
|
||||
data-project-group-header-drag-handle={
|
||||
isDraggableProjectGroupHeader ? '' : undefined
|
||||
}
|
||||
className={cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-[4px]',
|
||||
repoHeaderColor ? 'text-muted-foreground' : row.tone,
|
||||
isDraggableRepoHeader && 'hover:cursor-grab active:cursor-grabbing'
|
||||
(isDraggableRepoHeader || isDraggableProjectGroupHeader) &&
|
||||
'hover:cursor-grab active:cursor-grabbing'
|
||||
)}
|
||||
onPointerDown={
|
||||
isDraggableRepoHeader && projectIdForHeader
|
||||
? (event) => repoDrag.onHandlePointerDown(event, projectIdForHeader)
|
||||
: undefined
|
||||
: isDraggableProjectGroupHeader && projectGroupIdForHeader
|
||||
? (event) =>
|
||||
projectGroupDrag.onHandlePointerDown(
|
||||
event,
|
||||
projectGroupIdForHeader
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{row.repo ? (
|
||||
|
||||
@@ -68,6 +68,7 @@ async function renderTitleRename(props: {
|
||||
beginEditing: boolean
|
||||
disabled?: boolean
|
||||
onBeginEditingConsumed: () => void
|
||||
onRename?: (displayName: string) => Promise<void> | void
|
||||
}): Promise<unknown> {
|
||||
reactHookRuntime.index = 0
|
||||
const module = await import('./WorktreeTitleInlineRename')
|
||||
@@ -120,6 +121,27 @@ function findElementsByType(node: unknown, typeName: string): ReactElementLike[]
|
||||
return results
|
||||
}
|
||||
|
||||
function pressInputKey(
|
||||
input: ReactElementLike,
|
||||
key: string,
|
||||
options?: { isComposing?: boolean; keyCode?: number }
|
||||
): {
|
||||
preventDefault: ReturnType<typeof vi.fn>
|
||||
stopPropagation: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const event = {
|
||||
key,
|
||||
nativeEvent: {
|
||||
isComposing: options?.isComposing ?? false,
|
||||
keyCode: options?.keyCode ?? 13
|
||||
},
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
}
|
||||
;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event)
|
||||
return event
|
||||
}
|
||||
|
||||
describe('WorktreeTitleInlineRename beginEditing', () => {
|
||||
beforeEach(() => {
|
||||
reactHookRuntime.states = []
|
||||
@@ -152,4 +174,32 @@ describe('WorktreeTitleInlineRename beginEditing', () => {
|
||||
expect(onBeginEditingConsumed).toHaveBeenCalledTimes(1)
|
||||
expect(findElementsByType(rerender, 'input')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores IME composition Enter before committing the edited title', async () => {
|
||||
const onBeginEditingConsumed = vi.fn()
|
||||
const onRename = vi.fn()
|
||||
|
||||
await renderTitleRename({ beginEditing: true, onBeginEditingConsumed, onRename })
|
||||
let rerender = expandNode(
|
||||
await renderTitleRename({ beginEditing: false, onBeginEditingConsumed, onRename })
|
||||
)
|
||||
let input = findElementsByType(rerender, 'input')[0]
|
||||
;(input.props.onChange as (event: { target: { value: string } }) => void)({
|
||||
target: { value: '日本語 workspace' }
|
||||
})
|
||||
rerender = expandNode(
|
||||
await renderTitleRename({ beginEditing: false, onBeginEditingConsumed, onRename })
|
||||
)
|
||||
input = findElementsByType(rerender, 'input')[0]
|
||||
|
||||
const composingEvent = pressInputKey(input, 'Enter', { isComposing: true })
|
||||
|
||||
expect(composingEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(onRename).not.toHaveBeenCalled()
|
||||
|
||||
pressInputKey(input, 'Enter')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(onRename).toHaveBeenCalledWith('日本語 workspace')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type WorktreeTitleRenameCommit = { kind: 'cancel' } | { kind: 'save'; displayName: string }
|
||||
@@ -211,6 +212,11 @@ export function WorktreeTitleInlineRename({
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation()
|
||||
// Why: an Enter that only confirms a CJK IME candidate must not commit the
|
||||
// rename; wait for a non-composition Enter.
|
||||
if (isImeCompositionKeyDown(event)) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void commitRename()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function readWorktreeListSource(): string {
|
||||
return readFileSync(fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
describe('Project Group header drag DOM source', () => {
|
||||
it('renders concrete Project Group header drag attributes separately from repo headers', () => {
|
||||
const source = readWorktreeListSource()
|
||||
|
||||
expect(source).toContain('data-project-group-header-id={projectGroupIdForHeader}')
|
||||
expect(source).toContain('data-project-group-header-index={projectGroupHeaderIndex}')
|
||||
expect(source).toContain('data-project-group-header-bucket={projectGroupHeaderBucketKey}')
|
||||
expect(source).toContain('data-project-group-header-drag-handle=')
|
||||
})
|
||||
|
||||
it('commits Project Group manual sorting through updateProjectGroup tabOrder', () => {
|
||||
const source = readWorktreeListSource()
|
||||
|
||||
expect(source).toContain('const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)')
|
||||
expect(source).toContain('void updateProjectGroup(groupId, { tabOrder })')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user