From e59c04122d848904d9d02b246e0cc24b1393fd31 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:17:38 -0700 Subject: [PATCH 01/70] Fix Activity thread options trigger ref loop (#7096) --- .../activity/ActivityPrototypePage.tsx | 109 ++++++++++-------- .../ActivityThreadOptionsMenu.test.tsx | 65 +++++++++++ 2 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 38cd942c765..d296f5da471 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -873,6 +873,61 @@ function EventTime({ timestamp }: { timestamp: number }): React.JSX.Element { ) } +export function ActivityThreadOptionsMenu({ + compactMode, + hasUnreadThreads, + onCompactModeChange, + onMarkAllThreadsRead +}: { + compactMode: boolean + hasUnreadThreads: boolean + onCompactModeChange: (compactMode: boolean) => void + onMarkAllThreadsRead: () => void +}): React.JSX.Element { + return ( + + + + {/* Why: keep Tooltip and Dropdown from composing refs onto the same + button; the crash report's stack loops through Radix setRef. */} + + + + + + + + {translate('auto.components.activity.ActivityPrototypePage.a472a14700', 'More options')} + + + + onCompactModeChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate('auto.components.activity.ActivityPrototypePage.f70e4bec47', 'Compact mode')} + + + + {translate('auto.components.activity.ActivityPrototypePage.023ff75afe', 'Mark all read')} + + + + ) +} + function EventRepoBadge({ repo }: { repo: Repo | null }): React.JSX.Element | null { if (!repo) { return null @@ -1823,54 +1878,12 @@ export default function ActivityPrototypePage(): React.JSX.Element { the toolbar focused on the high-frequency Filter + unread toggle while still giving the action a stable home next to the list it acts on (rather than the titlebar). */} - - - - - - - - - {translate( - 'auto.components.activity.ActivityPrototypePage.a472a14700', - 'More options' - )} - - - - setCompactMode(checked === true)} - onSelect={(event) => event.preventDefault()} - > - {translate( - 'auto.components.activity.ActivityPrototypePage.f70e4bec47', - 'Compact mode' - )} - - - markAllThreadsRead()} - disabled={!hasUnreadThreads} - > - {translate( - 'auto.components.activity.ActivityPrototypePage.023ff75afe', - 'Mark all read' - )} - - - +
diff --git a/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx new file mode 100644 index 00000000000..b5d786f3568 --- /dev/null +++ b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom + +import { act, type ReactElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { TooltipProvider } from '@/components/ui/tooltip' +import { ActivityThreadOptionsMenu } from './ActivityPrototypePage' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function Harness({ + compactMode = false, + hasUnreadThreads = true +}: { + compactMode?: boolean + hasUnreadThreads?: boolean +}): ReactElement { + return ( + + + + ) +} + +describe('ActivityThreadOptionsMenu', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + document.body.replaceChildren() + }) + + it('opens without recursively updating composed Radix trigger refs', async () => { + await act(async () => { + root.render() + }) + + const trigger = container.querySelector( + 'button[aria-label="Thread list options"]' + ) + + expect(trigger).not.toBeNull() + expect(trigger?.parentElement?.tagName).toBe('SPAN') + + await act(async () => { + trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) + }) + + expect(document.body.textContent).toContain('Compact mode') + }) +}) From 4f0a141951fd91db413d90701ee3371667b3961e Mon Sep 17 00:00:00 2001 From: WONGIL Date: Thu, 2 Jul 2026 18:31:19 +0900 Subject: [PATCH 02/70] Fix mobile terminal Korean IME composition on Android (#7011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix Korean IME composition by deferring live terminal preedit The mobile terminal capture field previously sent and cleared every TextInput change, which can break Hangul composition on Android keyboards. Introduce a small commit model and extracted live-input hook so composed text is flushed deliberately while ASCII remains immediate. Constraint: React Native TextInput has no portable composition event for this path; the fix uses a bounded commit delay for likely IME text. Rejected: Native-module IME integration | unnecessary for the confirmed JS dispatch/clear failure and higher maintenance risk. Confidence: high Scope-risk: moderate Directive: Keep terminal.send payload shape and buffered command input unchanged; do not claim physical Samsung Keyboard QA without device evidence. Tested: cd mobile && pnpm exec vitest run src/terminal/terminal-live-text-commit.test.ts src/terminal/terminal-live-input.test.ts src/terminal/terminal-text-input-normalization.test.ts src/terminal/terminal-keyboard-type.test.ts --reporter=verbose Tested: cd mobile && pnpm exec tsc --noEmit Tested: cd mobile && pnpm exec oxlint src/terminal/terminal-live-text-commit.ts src/terminal/terminal-live-text-commit.test.ts src/terminal/use-terminal-live-input-commit.ts app/h/[hostId]/session/[worktreeId].tsx Not-tested: Physical Galaxy Fold7/Samsung Keyboard and Android emulator/Gboard QA were unavailable; device probes recorded no attached Android device. * Preserve pending Korean IME text before mobile accessory controls Accessory keys share the same pending live-input commit gate as TextInput keypress and submit paths, so control bytes cannot race ahead of composed Hangul. Constraint: React Native mobile input does not expose portable composition events for Samsung/Gboard IME paths. Rejected: Let accessory buttons keep sending directly | Direct sends can drop pending Hangul before Tab/Esc/Enter/Backspace reaches the PTY. Confidence: high Scope-risk: narrow Directive: Keep all terminal control-byte paths behind the pending live-input flush/local-edit decision before sending to the PTY. Tested: pnpm --dir mobile test; pnpm --dir mobile lint; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile exec oxfmt --check src/terminal/terminal-live-text-commit.ts src/terminal/terminal-live-text-commit.test.ts src/terminal/use-terminal-live-input-commit.ts src/terminal/use-terminal-live-accessory-input-commit.ts app/h/[hostId]/session/[worktreeId].tsx; git diff --cached --check Not-tested: Physical Galaxy Fold7 Samsung keyboard manual QA is still external-device only. * Prevent stale IME timer flushes after mobile terminal teardown Pending live-input timers now clear on hook unmount, and accessory Delete documents why it stays local without trimming pending IME text. Constraint: React Native TextInput lacks a portable composition lifecycle, so pending IME text is guarded by a bounded timer that must not survive screen teardown. Rejected: Use clearPendingLiveInputCommit during unmount | it would also touch React state/native props during teardown when only timer/ref cleanup is required. Confidence: high Scope-risk: narrow Directive: Any delayed terminal input commit must have an owner-lifecycle cleanup path before sending to the PTY. Tested: pnpm --dir mobile test; pnpm --dir mobile lint; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile exec vitest run src/terminal/terminal-live-text-commit.test.ts --reporter=verbose; pnpm --dir mobile exec oxfmt --check src/terminal/terminal-live-text-commit.ts src/terminal/use-terminal-live-input-commit.ts; git diff --check Not-tested: Physical Galaxy Fold7 Samsung keyboard manual QA remains unavailable in this environment. * Use semantic accessory edits for mobile IME commits Accessory Backspace/Delete now carry semantic local-edit intent from built-in keys instead of inferring intent from raw bytes, and submit handling is reconnected to the pure submit-sequence model. Constraint: Custom terminal accessory keys may produce the same bytes as built-ins but should still flush pending IME text before sending rather than being silently treated as hidden-input edits. Rejected: Classify local accessory edits by raw bytes | That couples future custom controls to current built-in byte encodings. Confidence: high Scope-risk: narrow Directive: Keep semantic input intent separate from terminal byte payloads when pending IME text is present. Tested: pnpm --dir mobile test; pnpm --dir mobile lint; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile exec oxfmt --check src/terminal/terminal-live-text-commit.ts src/terminal/terminal-live-text-commit.test.ts src/terminal/use-terminal-live-input-commit.ts src/terminal/use-terminal-live-accessory-input-commit.ts app/h/[hostId]/session/[worktreeId].tsx; git diff --check Not-tested: Physical Galaxy Fold7 Samsung keyboard manual QA remains unavailable in this environment. * Respect IME flush failures before control input Propagate terminal.send success from pending Korean IME text before sending Enter, Tab, or accessory bytes, while keeping custom no-pending accessory bytes on the original direct path. Constraint: PR #7011 review required follow-up control bytes only after the pending composed text send actually succeeds. Rejected: Treating send invocation as success | It can still reject or no-op when RPC state changed. Confidence: high Scope-risk: narrow Directive: Keep pending IME flush paths async-success-aware before adding new terminal control inputs. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; targeted no-excuse clean for mobile/src/terminal changed files. Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations. * Serialize mobile IME flushes before live controls Treat terminal.send as successful only when the RPC response is ok and the runtime send result is accepted, then route all live-input control sends through a shared in-flight pending-flush barrier. Constraint: PR #7011 review found that resolved RPC promises and per-call sequencing were not enough to prove pending Hangul text reached the PTY before follow-up controls. Rejected: Only awaiting each flush-then-send call | Repeatable accessory keys and no-pending sends can arrive while the first flush is still in flight. Confidence: high Scope-risk: moderate Directive: Keep future mobile terminal control paths behind the pending-flush barrier whenever IME text may be in flight. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; no-excuse clean for terminal changed files. Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations. * Queue current IME snapshots behind active flushes Drain the pending snapshot captured by a control action after any already-active terminal send, and make accessory commit handling explicit so raw fallback is not encoded as an inverted boolean. Constraint: Architecture review found the previous single-slot barrier could wait for an older flush while skipping newly pending Hangul text. Rejected: Reusing the prior in-flight promise as the current flush result | It proves only an older snapshot, not the current pending buffer. Confidence: high Scope-risk: narrow Directive: New mobile terminal control paths must distinguish allow-raw, handled, and suppress-raw outcomes explicitly. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; no-excuse clean for terminal changed files. Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations. * Preserve accessory raw-send terminal targets Capture the terminal handle at accessory keypress time and suppress raw fallback if the active live terminal changes while waiting for pending IME flushes. Constraint: Independent review found raw accessory bytes could retarget to a different terminal after an async IME flush barrier. Rejected: Re-reading activeHandleRef as the send target after await | It can point at a different terminal than the keypress belonged to. Confidence: high Scope-risk: narrow Directive: Raw accessory fallback must use the keypress-time target and revalidate it after any await. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; no-excuse clean for terminal changed files. Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations. * Document accessory flush barrier intent Make the non-obvious raw accessory wait/suppress behavior explicit so future changes preserve IME-before-control ordering. Constraint: CodeRabbit requested a why-comment for the send-now accessory branch. Rejected: Leaving the barrier semantics implicit | The branch can otherwise look like unnecessary async defensive code. Confidence: high Scope-risk: narrow Directive: Keep comments focused on why raw accessory bytes wait behind IME flushes. Tested: targeted terminal vitest suite; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt check for changed file. Not-tested: Physical Galaxy Fold7 Samsung keyboard. * Preserve buffered accessory raw sends Keep the stale-handle guard focused on the captured active terminal instead of live-input opt-in state, so buffered mode keeps existing accessory key behavior while async live-input waits still cannot retarget to another terminal. Constraint: Buffered command input behavior must remain unchanged while fixing mobile Korean IME live input ordering. Rejected: Requiring live-input enabled handles for raw accessory fallback | suppresses valid buffered-mode accessory sends. Confidence: high Scope-risk: narrow Directive: Do not use live-input opt-in state as terminal liveness for raw accessory sends; validate captured target, active terminal tab, connection, and client instead. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile terminal/session files; TypeScript no-excuse checker for changed terminal files. Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval. * Keep Hangul IME text pending until explicit flush Avoid timer-driven PTY writes for Hangul candidates so paused Korean composition cannot leak intermediate jamo, while preserving the bounded settle timer for non-Hangul IME text. Also keep disabled live-input accessory fallback behind any existing pending flush barrier. Constraint: React Native TextInput does not expose a portable composition lifecycle on this mobile surface. Rejected: Fixed 150ms auto-flush for Hangul | can emit ㅎ or 하 if the user pauses mid-composition. Confidence: high Scope-risk: narrow Directive: Treat Hangul candidates as pending until submit/control/accessory flush; do not reintroduce idle timer commits for Hangul without device-level composition evidence. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile terminal/session files; TypeScript no-excuse checker for changed terminal files. Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval. * Gate dictation toast on accepted live send Honor the async live-input sender contract so the mobile UI reports dictation insertion only after terminal.send is accepted. Constraint: sendLiveTerminalInput now returns false for stale, disconnected, oversized, or rejected terminal sends. Rejected: Toasting immediately after dispatch | reports success for sends that never reached the PTY. Confidence: high Scope-risk: narrow Directive: Treat live-input UI success as terminal.send acceptance, not request dispatch. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check app/h/[hostId]/session/[worktreeId].tsx. Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval. * Keep accessory edits on Hangul pending path Make accessory local edits reuse the Hangul-aware defer policy so built-in Backspace/Delete cannot reintroduce timer-driven Hangul PTY writes. Constraint: Hangul IME candidates must remain pending until explicit submit/control/accessory flush. Rejected: Reusing the non-Hangul 150ms settle timer for accessory local edits | can leak pending Hangul after Backspace/Delete. Confidence: high Scope-risk: narrow Directive: Any future pending-text reschedule must use getTerminalLiveDeferredTextDelayMs instead of a hardcoded timer. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile terminal/session files; TypeScript no-excuse checker for changed terminal files. Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval. * Prove Hangul live-input hook ordering Add a direct hook-level regression so Android Korean IME fixes are covered at the orchestration boundary, not only by lower-level helpers. Constraint: React Native mobile TextInput lacks portable composition lifecycle events in this path. Rejected: Relying only on helper tests | misses hook-level pending flush and submit ordering. Confidence: high Scope-risk: narrow Directive: Keep Hangul candidates pending until an explicit terminal action flushes them. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile files; no-excuse on terminal modules Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * Keep accessory raw-send tests precise Remove a duplicate raw-target assertion whose title implied disabled live-input behavior that is covered at the accessory commit boundary instead. Constraint: Anti-slop cleanup must preserve existing Hangul/accessory behavior and stay within changed terminal tests. Rejected: Keeping the duplicate disabled-input wording | it tests the same active-terminal predicate as the preceding case. Confidence: high Scope-risk: narrow Directive: Test disabled live-input buffering in the accessory commit layer, not in the raw-target predicate helper. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed mobile files; terminal no-excuse checker Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * Explain stale mobile terminal send gates Document why async IME flush paths re-check terminal/client refs before sending raw bytes or reporting live-send success. Constraint: CodeRabbit review requested short why comments for non-obvious stale-send safety gates. Rejected: Leaving the gates undocumented | future edits could remove the stale-target suppression contract. Confidence: high Scope-risk: narrow Directive: Keep async terminal sends guarded by current client, active handle, tab type, and connection state. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed mobile files; terminal no-excuse checker Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * Run mobile IME hook tests through effects Move the Hangul live-input hook regression from server rendering to react-test-renderer so effect cleanup and unmount timer cancellation are exercised. Constraint: @testing-library/react-native imports React Native's Flow entry under this Vitest setup, so the narrow effect-running renderer is the compatible test surface. Rejected: Keeping renderToString | it never runs useEffect cleanup and missed the pending timer cleanup path. Rejected: Adding @testing-library/react-native directly | it failed before tests with React Native Flow syntax under the current Vitest transform. Confidence: high Scope-risk: narrow Directive: Hook-level IME tests must use a renderer that runs effects when asserting pending flush cleanup. Tested: vitest targeted terminal tests; pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile files; terminal no-excuse checker Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * Keep hook lifecycle tests quiet Suppress only the react-test-renderer deprecation warning around the effect-running hook harness so real console errors still surface. Constraint: CodeRabbit flagged React 19 renderer warning noise; @testing-library/react-native remains incompatible with the current Vitest/RN Flow transform path. Rejected: Global console silencing | it would hide unrelated test failures. Confidence: high Scope-risk: narrow Directive: Keep the renderer warning suppression scoped to this hook harness and pass all other console errors through. Tested: vitest targeted terminal tests; pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile files; terminal no-excuse checker Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * fix: flush pending mobile IME input before external sends * fix: guard terminal command finished event dispatch --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../app/h/[hostId]/session/[worktreeId].tsx | 326 +++++++----------- mobile/package.json | 2 + mobile/pnpm-lock.yaml | 63 +++- .../session/mobile-image-attachment.test.ts | 26 ++ mobile/src/session/mobile-image-attachment.ts | 7 +- .../session/use-mobile-image-attachment.ts | 8 +- .../src/session/use-mobile-terminal-paste.ts | 200 +++++++++++ .../terminal/terminal-live-accessory-input.ts | 21 ++ ...nal-live-accessory-raw-send-target.test.ts | 49 +++ ...terminal-live-accessory-raw-send-target.ts | 17 + .../terminal-live-control-send-order.test.ts | 74 ++++ .../terminal-live-control-send-order.ts | 12 + .../terminal/terminal-live-input-sender.ts | 1 + .../terminal-live-pending-flush-state.test.ts | 146 ++++++++ .../terminal-live-pending-flush-state.ts | 29 ++ .../terminal-live-text-commit.test.ts | 234 +++++++++++++ .../src/terminal/terminal-live-text-commit.ts | 145 ++++++++ .../terminal-send-rpc-response.test.ts | 53 +++ .../terminal/terminal-send-rpc-response.ts | 15 + ...rminal-live-accessory-input-commit.test.ts | 52 +++ ...se-terminal-live-accessory-input-commit.ts | 124 +++++++ .../use-terminal-live-input-commit.test.ts | 184 ++++++++++ .../use-terminal-live-input-commit.ts | 236 +++++++++++++ .../use-terminal-live-pending-input-flush.ts | 139 ++++++++ .../hooks/terminal-command-finished-event.ts | 5 + 25 files changed, 1945 insertions(+), 223 deletions(-) create mode 100644 mobile/src/session/use-mobile-terminal-paste.ts create mode 100644 mobile/src/terminal/terminal-live-accessory-input.ts create mode 100644 mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts create mode 100644 mobile/src/terminal/terminal-live-accessory-raw-send-target.ts create mode 100644 mobile/src/terminal/terminal-live-control-send-order.test.ts create mode 100644 mobile/src/terminal/terminal-live-control-send-order.ts create mode 100644 mobile/src/terminal/terminal-live-input-sender.ts create mode 100644 mobile/src/terminal/terminal-live-pending-flush-state.test.ts create mode 100644 mobile/src/terminal/terminal-live-pending-flush-state.ts create mode 100644 mobile/src/terminal/terminal-live-text-commit.test.ts create mode 100644 mobile/src/terminal/terminal-live-text-commit.ts create mode 100644 mobile/src/terminal/terminal-send-rpc-response.test.ts create mode 100644 mobile/src/terminal/terminal-send-rpc-response.ts create mode 100644 mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts create mode 100644 mobile/src/terminal/use-terminal-live-accessory-input-commit.ts create mode 100644 mobile/src/terminal/use-terminal-live-input-commit.test.ts create mode 100644 mobile/src/terminal/use-terminal-live-input-commit.ts create mode 100644 mobile/src/terminal/use-terminal-live-pending-input-flush.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 1912f7a04a4..3166645727e 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1,8 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Animated, AppState, Linking, type AppStateStatus } from 'react-native' import * as Clipboard from 'expo-clipboard' -import { ImageManipulator, SaveFormat } from 'expo-image-manipulator' -import { File as FsFile, Paths } from 'expo-file-system' import { BackHandler, FlatList, @@ -95,14 +93,18 @@ import { getVisibleTerminalAccessoryKeys, loadTerminalAccessoryLayout } from '../../../../src/terminal/terminal-accessory-layout' +import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input' +import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target' import { clearTerminalLiveInputFocusTimer, defaultTerminalLiveInputHandles, - getTerminalLiveSpecialKeyBytes, isTerminalLiveInputWithinByteLimit, pruneTerminalLiveInputHandles, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' +import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' +import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' +import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' import { getTerminalCommandKeyboardType, getTerminalLiveInputKeyboardType @@ -162,13 +164,8 @@ import { type MobileNewTabAgentOption, type MobileNewTabAgentSettings } from '../../../../src/session/mobile-new-tab-agent-options' -import { - buildMobileImagePastePayload, - prepareMobileClipboardImageBase64, - saveMobileClipboardImageAsTempFile, - type MobileClipboardImageResizer -} from '../../../../src/session/mobile-clipboard-image' import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment' +import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste' import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions' import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind' @@ -233,52 +230,9 @@ import type { TerminalGestureInputQueue } from './mobile-session-route-types' -const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i -const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 +type TerminalLiveAccessoryInput = ReturnType -// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and -// photos can exceed the upload byte budget; resize the raster down to fit before upload. -// The image is staged to a temp file first because the iOS ImageManipulator loader -// (Data(contentsOf:)) cannot decode large base64 data URIs — it needs a file:// URI. -const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => { - const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '') - const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`) - let context: ReturnType | null = null - let rendered: Awaited< - ReturnType['renderAsync']> - > | null = null - let resultUri: string | null = null - try { - file.create({ overwrite: true }) - file.write(base64, { encoding: 'base64' }) - context = ImageManipulator.manipulate(file.uri) - context.resize({ width: target.width, height: target.height }) - rendered = await context.renderAsync() - const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true }) - resultUri = result.uri - // Why: empty base64 would pass the downstream base64 check and upload a corrupt - // image, so fail loudly here instead of silently sending an invalid payload. - if (!result.base64) { - throw new Error('Failed to encode resized clipboard image') - } - return { data: result.base64, width: result.width, height: result.height } - } finally { - rendered?.release() - context?.release() - if (resultUri) { - try { - new FsFile(resultUri).delete() - } catch { - // Best-effort cleanup; ImageManipulator saves into cache for every retry. - } - } - try { - file.delete() - } catch { - // Best-effort cleanup; the OS reclaims the cache directory regardless. - } - } -} +const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 function getActiveTabIdForHandle( tabs: MobileSessionTab[], @@ -1034,6 +988,7 @@ export default function SessionScreen() { const terminalRefs = useRef>(new Map()) const liveInputRef = useRef(null) const liveInputFocusTimerRef = useRef | null>(null) + const sendLiveTerminalInputRef = useRef(async () => false) const sessionTabActionSheetKeyboardHideSubRef = useRef | null>(null) @@ -1068,6 +1023,7 @@ export default function SessionScreen() { // render where client was still null/connecting, silently no-opping the // in-app-browser open). Route through a ref kept current every render. const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise) | null>(null) + const initialEmptySessionAutoCreateRef = useRef(null) const markdownSaveSeqRef = useRef>(new Map()) const markdownSaveInFlightRef = useRef>(new Set()) @@ -1094,6 +1050,24 @@ export default function SessionScreen() { const [terminalFrameWidth, setTerminalFrameWidth] = useState(0) const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null + const { + clearPendingLiveInputCommit, + flushPendingLiveInputBeforeExternalSend, + handleLiveInputAccessoryBytes, + handleLiveInputChange, + handleLiveInputKeyPress, + handleLiveInputSubmit + } = useTerminalLiveInputCommit({ + activeHandle, + activeHandleRef, + activeSessionTabType: activeSessionTab?.type, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture + }) const canSend = connState === 'connected' && activeHandle != null && @@ -1244,8 +1218,16 @@ export default function SessionScreen() { if (!insertHandle) { return } - sendLiveTerminalInput(insertHandle, route.text) - showToast('Dictation inserted') + void (async () => { + const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(insertHandle) + if (!flushedPendingInput) { + return + } + const sent = await sendLiveTerminalInput(insertHandle, route.text) + if (sent) { + showToast('Dictation inserted') + } + })() return } setInput((current) => appendBufferedDictation(current, route.text)) @@ -2680,7 +2662,7 @@ export default function SessionScreen() { terminalsRef.current = [] setSessionTabs([]) setActiveSessionTabId(null) - setLiveInputCapture('') + clearPendingLiveInputCommit() liveInputTerminalHandlesRef.current = new Set() defaultedLiveInputTerminalHandlesRef.current = new Set() setLiveInputTerminalHandles(new Set()) @@ -2690,9 +2672,16 @@ export default function SessionScreen() { return () => { sessionTabActionSheetRequestSeqRef.current += 1 sessionTabActionSheetKeyboardHideSubRef.current?.remove() + clearPendingLiveInputCommit() clearDelayedActionTimers() } - }, [clearDelayedActionTimers, clearTerminalCache, hostId, worktreeId]) + }, [ + clearDelayedActionTimers, + clearPendingLiveInputCommit, + clearTerminalCache, + hostId, + worktreeId + ]) useEffect(() => { if (connState !== 'connected') { @@ -3097,46 +3086,62 @@ export default function SessionScreen() { } } - async function handleAccessoryKey(bytes: string) { + async function handleAccessoryKey(input: TerminalLiveAccessoryInput) { if (!client || !activeHandle || !canSend) { return } - - try { - await client.sendRequest('terminal.send', { - terminal: activeHandle, - text: bytes, + const targetHandle = activeHandle + const accessoryCommit = await handleLiveInputAccessoryBytes(input) + if (accessoryCommit.kind !== 'allow-raw') { + return + } + const currentClient = clientRef.current + // Why: async IME flushing can outlive the original terminal selection. + const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: activeHandleRef.current, + activeSessionTabType: activeSessionTabTypeRef.current + }) + if (!currentClient || !rawSendTarget || connStateRef.current !== 'connected') { + return + } + await currentClient + .sendRequest('terminal.send', { + terminal: rawSendTarget, + text: input.bytes, enter: false, ...(deviceTokenRef.current ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } : {}) }) - } catch { - // Transient failure - } + .then( + () => undefined, + () => undefined + ) } const sendLiveTerminalInput = useCallback( - (handle: string, bytes: string) => { + async (handle: string, bytes: string): Promise => { const text = normalizeTerminalTextInput(bytes) if (text.length === 0) { - return + return false } if (!isTerminalLiveInputWithinByteLimit(text)) { triggerError() showToast('Input too large (max 256 KiB)', 1500) - return + return false } const rpc = clientRef.current + // Why: callers suppress follow-up controls/toasts when this live send is stale. if ( !rpc || connStateRef.current !== 'connected' || handle !== activeHandleRef.current || activeSessionTabTypeRef.current !== 'terminal' ) { - return + return false } - void rpc + return rpc .sendRequest('terminal.send', { terminal: handle, text, @@ -3145,12 +3150,11 @@ export default function SessionScreen() { ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } : {}) }) - .catch(() => { - // Transient failure - }) + .then(isTerminalSendRpcAccepted, () => false) }, [showToast] ) + sendLiveTerminalInputRef.current = sendLiveTerminalInput const focusLiveInput = useCallback(() => { if (!canSend || !liveInputEnabled) { @@ -3387,70 +3391,14 @@ export default function SessionScreen() { liveInputTerminalHandlesRef.current = next return next }) - setLiveInputCapture('') + clearPendingLiveInputCommit() if (nextEnabled) { scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) } else { clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef) liveInputRef.current?.blur() } - }, [activeHandle, liveInputTerminalHandles]) - - const handleLiveInputChange = useCallback( - (text: string) => { - if (!activeHandle) { - setLiveInputCapture('') - liveInputRef.current?.setNativeProps({ text: '' }) - return - } - if (!liveInputTerminalHandles.has(activeHandle)) { - setLiveInputCapture('') - liveInputRef.current?.setNativeProps({ text: '' }) - return - } - const normalizedText = normalizeTerminalTextInput(text) - if (normalizedText.length > 0) { - sendLiveTerminalInput(activeHandle, normalizedText) - } - setLiveInputCapture('') - // Why: the field is only a keyboard capture surface. Clearing the - // native value prevents subsequent phone-keyboard events from replaying - // already-sent characters when React state remains the empty string. - liveInputRef.current?.setNativeProps({ text: '' }) - }, - [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput] - ) - - const handleLiveInputKeyPress = useCallback( - (event: { nativeEvent: { key: string } }) => { - if (!activeHandle) { - return - } - if (!liveInputTerminalHandles.has(activeHandle)) { - return - } - const bytes = getTerminalLiveSpecialKeyBytes(event.nativeEvent.key) - if (!bytes) { - return - } - sendLiveTerminalInput(activeHandle, bytes) - setLiveInputCapture('') - liveInputRef.current?.setNativeProps({ text: '' }) - }, - [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput] - ) - - const handleLiveInputSubmit = useCallback(() => { - if (!activeHandle) { - return - } - if (!liveInputTerminalHandles.has(activeHandle)) { - return - } - sendLiveTerminalInput(activeHandle, '\r') - setLiveInputCapture('') - liveInputRef.current?.setNativeProps({ text: '' }) - }, [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]) + }, [activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles]) const allowTerminalGestureInput = useCallback( (handle: string, sequenceCount: number): boolean => { @@ -3650,11 +3598,11 @@ export default function SessionScreen() { } }, []) const startAccessoryRepeat = useCallback( - (bytes: string) => { + (input: TerminalLiveAccessoryInput) => { stopAccessoryRepeat() repeatTimeoutRef.current = setTimeout(() => { repeatIntervalRef.current = setInterval(() => { - void handleAccessoryKeyRef.current(bytes) + void handleAccessoryKeyRef.current(input) }, 45) }, 400) }, @@ -3673,11 +3621,13 @@ export default function SessionScreen() { clearToastHideTimer() clearDelayedActionTimers() clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef) + clearPendingLiveInputCommit() sessionTabActionSheetRequestSeqRef.current += 1 clearSessionTabActionSheetKeyboardListener() stopAccessoryRepeat() }, [ + clearPendingLiveInputCommit, clearDelayedActionTimers, clearSessionTabActionSheetKeyboardListener, clearTerminalCache, @@ -3801,84 +3751,38 @@ export default function SessionScreen() { }) }, []) - const handlePaste = useCallback(async () => { - if (!client || !activeHandle || !canSend) { - return - } - try { - const text = await Clipboard.getStringAsync() - let payload: string | null = null - if (text.length > 0) { - const modes = ptyModesRef.current.get(activeHandle) || { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false - } - const wrap = modes.bracketedPasteMode && !modes.altScreen - // Why: strip embedded bracketed-paste markers from clipboard text so a - // malicious copy containing `\x1b[201~` can't terminate paste mode early - // and have the trailing bytes interpreted as shell commands. Matches - // xterm.js / iTerm2 behavior. - // eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping - const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text - payload = wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized - } else { - const image = await Clipboard.getImageAsync({ format: 'png' }) - if (!image) { - refreshCanPaste() - return - } - const connectionId = await getActiveWorktreeConnectionId() - const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage) - const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, { - connectionId - }) - payload = buildMobileImagePastePayload(imagePath) - } - - const wrappedBytes = new TextEncoder().encode(payload).byteLength - if (wrappedBytes > 256 * 1024) { - triggerError() - // eslint-disable-next-line no-console - console.warn('[mobile-clip] paste oversized', { wrappedBytes }) - showToast('Paste too large (max 256 KiB)', 1500) - return - } - await client.sendRequest('terminal.send', { - terminal: activeHandle, - text: payload, - enter: false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) - triggerSelection() - refreshCanPaste() - } catch (e) { - triggerError() - const err = e as { name?: string; message?: string } - const isDisconnected = connState !== 'connected' - // eslint-disable-next-line no-console - console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message }) - if (isDisconnected) { - showToast('Paste failed (disconnected)', 1500) - } else if (err.message === 'Clipboard image is too large') { - showToast('Image too large to paste', 1500) - } else { - showToast('Paste failed', 1500) - } - } - }, [ + const handlePaste = useMobileTerminalPaste({ client, activeHandle, + activeHandleRef, + activeSessionTabTypeRef, canSend, connState, + connStateRef, + clientRef, + deviceTokenRef, + flushPendingLiveInputBeforeExternalSend, getActiveWorktreeConnectionId, + onError: triggerError, + onSuccess: triggerSelection, + ptyModesRef, refreshCanPaste, showToast - ]) + }) + + const flushPendingLiveInputBeforeAttachmentSend = useCallback( + async (targetHandle: string): Promise => { + const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) + // Why: image picking/upload and IME flushing can outlive the original tab. + return ( + flushedPendingInput && + connStateRef.current === 'connected' && + targetHandle === activeHandleRef.current && + activeSessionTabTypeRef.current === 'terminal' + ) + }, + [flushPendingLiveInputBeforeExternalSend] + ) const { attachImage, isAttaching } = useMobileImageAttachment({ client, @@ -3886,6 +3790,7 @@ export default function SessionScreen() { canSend, connState, deviceTokenRef, + beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend, getActiveWorktreeConnectionId, showToast, onSuccess: triggerSelection, @@ -5046,8 +4951,9 @@ export default function SessionScreen() { if (!key.repeatable) { return } - void handleAccessoryKey(key.bytes) - startAccessoryRepeat(key.bytes) + const input = createTerminalLiveAccessoryInput(key) + void handleAccessoryKey(input) + startAccessoryRepeat(input) }} onPressOut={() => { if (key.repeatable) { @@ -5058,7 +4964,7 @@ export default function SessionScreen() { if (key.repeatable) { return } - void handleAccessoryKey(key.bytes) + void handleAccessoryKey(createTerminalLiveAccessoryInput(key)) }} accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`} > @@ -5082,7 +4988,7 @@ export default function SessionScreen() { !canSend && styles.accessoryKeyDisabled ]} disabled={!canSend} - onPress={() => void handleAccessoryKey(key.bytes)} + onPress={() => void handleAccessoryKey({ bytes: key.bytes })} onLongPress={() => { triggerMediumImpact() setDeleteKeyTarget(key) diff --git a/mobile/package.json b/mobile/package.json index 734519e9747..9d75502765f 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -60,10 +60,12 @@ "devDependencies": { "@types/react": "^19.2.14", "@types/react-native": "^0.73.0", + "@types/react-test-renderer": "19.1.0", "@types/ws": "^8.18.1", "expo-module-scripts": "^55.0.2", "oxfmt": "^0.52.0", "oxlint": "^1.71.0", + "react-test-renderer": "19.2.6", "tsx": "^4.22.4", "typescript": "^5.9.3", "vite": "^8.0.16", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 7785a4d05c6..aecf79fe0ee 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) expo-router: specifier: ^55.0.14 - version: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e) + version: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa) expo-secure-store: specifier: ^55.0.13 version: 55.0.13(expo@55.0.23) @@ -135,18 +135,24 @@ importers: '@types/react-native': specifier: ^0.73.0 version: 0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) + '@types/react-test-renderer': + specifier: 19.1.0 + version: 19.1.0 '@types/ws': specifier: ^8.18.1 version: 8.18.1 expo-module-scripts: specifier: ^55.0.2 - version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) + version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) oxfmt: specifier: ^0.52.0 version: 0.52.0 oxlint: specifier: ^1.71.0 version: 1.71.0 + react-test-renderer: + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) tsx: specifier: ^4.22.4 version: 4.22.4 @@ -1724,48 +1730,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.52.0': resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.52.0': resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.52.0': resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.52.0': resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.52.0': resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.52.0': resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.52.0': resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.52.0': resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} @@ -1838,48 +1852,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.71.0': resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.71.0': resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.71.0': resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.71.0': resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.71.0': resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.71.0': resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.71.0': resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.71.0': resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} @@ -2341,36 +2363,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.3': resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.1.3': resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.3': resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.3': resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.3': resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.3': resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} @@ -4632,24 +4660,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -5424,6 +5456,11 @@ packages: peerDependencies: react: ^19.2.0 + react-test-renderer@19.2.6: + resolution: {integrity: sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw==} + peerDependencies: + react: ^19.2.6 + react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -7825,7 +7862,7 @@ snapshots: ws: 8.21.0 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e) + expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa) react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -8093,7 +8130,7 @@ snapshots: react: 19.2.6 optionalDependencies: '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) - expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e) + expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa) react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - supports-color @@ -9055,14 +9092,14 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)': + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: jest-matcher-utils: 30.3.0 picocolors: 1.1.1 pretty-format: 30.3.0 react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) - react-test-renderer: 19.2.0(react@19.2.6) + react-test-renderer: 19.2.6(react@19.2.6) redent: 3.0.0 optionalDependencies: jest: 29.7.0(@types/node@25.9.3) @@ -10651,7 +10688,7 @@ snapshots: expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) expo-json-utils: 55.0.2 - expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6): + expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@babel/cli': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) @@ -10659,7 +10696,7 @@ snapshots: '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@expo/npm-proofread': 1.0.1 '@expo/spawn-async': 1.7.2 - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) '@tsconfig/node18': 18.2.6 '@types/jest': 29.5.14 babel-plugin-dynamic-import-node: 2.3.3 @@ -10734,7 +10771,7 @@ snapshots: - supports-color - typescript - expo-router@55.0.14(ce047ddbdcb481fe003f0510f5198c7e): + expo-router@55.0.14(e0a8113e3689d84edb7af23d00a72ffa): dependencies: '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -10771,7 +10808,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) optionalDependencies: - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) react-dom: 19.2.6(react@19.2.6) react-native-gesture-handler: 2.31.2(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) react-native-reanimated: 4.3.0(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -12948,6 +12985,12 @@ snapshots: react-is: 19.2.6 scheduler: 0.27.0 + react-test-renderer@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + react-is: 19.2.6 + scheduler: 0.27.0 + react@19.2.6: {} readdirp@3.6.0: diff --git a/mobile/src/session/mobile-image-attachment.test.ts b/mobile/src/session/mobile-image-attachment.test.ts index 3a94348aab4..1bf3df59e74 100644 --- a/mobile/src/session/mobile-image-attachment.test.ts +++ b/mobile/src/session/mobile-image-attachment.test.ts @@ -118,4 +118,30 @@ describe('attachMobileImageToTerminal', () => { const sendCall = client.calls.find((c) => c.method === 'terminal.send') expect(sendCall?.params).not.toHaveProperty('client') }) + + it('waits for pending live input before sending the image payload', async () => { + const client = clientWithResponses([ + { + id: 'start', + ok: false, + error: { code: 'method_not_found', message: 'no' }, + _meta: { runtimeId: 'r' } + }, + ok('save', '/tmp/pending.png') + ]) + const beforeTerminalSend = vi.fn(async () => false) + + const sent = await attachMobileImageToTerminal('library', { + client, + terminal: 'term-pending', + deviceToken: null, + getConnectionId: async () => null, + pickImage: vi.fn().mockResolvedValue({ base64: 'DDDD' }), + beforeTerminalSend + }) + + expect(sent).toBe(false) + expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending') + expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) + }) }) diff --git a/mobile/src/session/mobile-image-attachment.ts b/mobile/src/session/mobile-image-attachment.ts index 507b58eb671..b7d14d9a04d 100644 --- a/mobile/src/session/mobile-image-attachment.ts +++ b/mobile/src/session/mobile-image-attachment.ts @@ -16,6 +16,7 @@ export type AttachMobileImageDeps = { // start — lets the UI show a sending spinner only for the transfer, not the // (potentially long) time the picker is open. readonly onUploadStart?: () => void + readonly beforeTerminalSend?: (terminal: string) => Promise } // Uploads a picked image to the host and pastes the resulting file path into the @@ -30,7 +31,8 @@ export async function attachMobileImageToTerminal( deviceToken, getConnectionId, pickImage, - onUploadStart + onUploadStart, + beforeTerminalSend }: AttachMobileImageDeps ): Promise { const picked = await pickImage(source) @@ -45,6 +47,9 @@ export async function attachMobileImageToTerminal( // Why: a generated image path is terminal image injection, so it's always // bracketed (matching desktop paste) regardless of terminal mode. const payload = buildMobileImagePastePayload(imagePath) + if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) { + return false + } await client.sendRequest('terminal.send', { terminal, text: payload, diff --git a/mobile/src/session/use-mobile-image-attachment.ts b/mobile/src/session/use-mobile-image-attachment.ts index 3894928f4f5..417dd8d699b 100644 --- a/mobile/src/session/use-mobile-image-attachment.ts +++ b/mobile/src/session/use-mobile-image-attachment.ts @@ -24,6 +24,7 @@ type UseMobileImageAttachmentArgs = { readonly showToast: ShowToast readonly onSuccess: () => void readonly onError: () => void + readonly beforeTerminalSend?: (terminal: string) => Promise } type MobileImageAttachment = { @@ -46,7 +47,8 @@ export function useMobileImageAttachment({ getActiveWorktreeConnectionId, showToast, onSuccess, - onError + onError, + beforeTerminalSend }: UseMobileImageAttachmentArgs): MobileImageAttachment { const [isAttaching, setIsAttaching] = useState(false) const attachImage = useCallback( @@ -61,7 +63,8 @@ export function useMobileImageAttachment({ deviceToken: deviceTokenRef.current, getConnectionId: getActiveWorktreeConnectionId, pickImage: pickMobileImage, - onUploadStart: () => setIsAttaching(true) + onUploadStart: () => setIsAttaching(true), + beforeTerminalSend }) // Cancelled picker: no error, no toast. if (sent) { @@ -88,6 +91,7 @@ export function useMobileImageAttachment({ }, [ activeHandle, + beforeTerminalSend, canSend, client, connState, diff --git a/mobile/src/session/use-mobile-terminal-paste.ts b/mobile/src/session/use-mobile-terminal-paste.ts new file mode 100644 index 00000000000..e3148be67f0 --- /dev/null +++ b/mobile/src/session/use-mobile-terminal-paste.ts @@ -0,0 +1,200 @@ +import { useCallback, type RefObject } from 'react' +import * as Clipboard from 'expo-clipboard' +import { File as FsFile, Paths } from 'expo-file-system' +import { ImageManipulator, SaveFormat } from 'expo-image-manipulator' +import type { TerminalModes } from '../terminal/TerminalWebView' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import { + buildMobileImagePastePayload, + prepareMobileClipboardImageBase64, + saveMobileClipboardImageAsTempFile, + type MobileClipboardImageResizer +} from './mobile-clipboard-image' + +const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i + +// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and +// photos can exceed the upload byte budget; resize the raster down to fit before upload. +// The iOS ImageManipulator loader cannot decode large base64 data URIs, so use a file. +const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => { + const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '') + const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`) + let context: ReturnType | null = null + let rendered: Awaited< + ReturnType['renderAsync']> + > | null = null + let resultUri: string | null = null + try { + file.create({ overwrite: true }) + file.write(base64, { encoding: 'base64' }) + context = ImageManipulator.manipulate(file.uri) + context.resize({ width: target.width, height: target.height }) + rendered = await context.renderAsync() + const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true }) + resultUri = result.uri + // Why: empty base64 would pass the downstream base64 check and upload a corrupt + // image, so fail loudly here instead of silently sending an invalid payload. + if (!result.base64) { + throw new Error('Failed to encode resized clipboard image') + } + return { data: result.base64, width: result.width, height: result.height } + } finally { + rendered?.release() + context?.release() + if (resultUri) { + try { + new FsFile(resultUri).delete() + } catch { + // Best-effort cleanup; ImageManipulator saves into cache for every retry. + } + } + try { + file.delete() + } catch { + // Best-effort cleanup; the OS reclaims the cache directory regardless. + } + } +} + +function buildMobileTerminalClipboardTextPayload( + text: string, + modes: TerminalModes | undefined +): string { + const wrap = modes?.bracketedPasteMode === true && !modes.altScreen + // Why: strip embedded bracketed-paste markers so copied text cannot terminate + // paste mode early and turn trailing bytes into shell commands. + // eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping + const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text + return wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized +} + +type UseMobileTerminalPasteOptions = { + readonly activeHandle: string | null + readonly activeHandleRef: RefObject + readonly activeSessionTabTypeRef: RefObject + readonly canSend: boolean + readonly client: RpcClient | null + readonly clientRef: RefObject + readonly connState: ConnectionState + readonly connStateRef: RefObject + readonly deviceTokenRef: RefObject + readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly getActiveWorktreeConnectionId: () => Promise + readonly onError: () => void + readonly onSuccess: () => void + readonly ptyModesRef: RefObject> + readonly refreshCanPaste: () => void + readonly showToast: (message: string, durationMs?: number) => void +} + +export function useMobileTerminalPaste({ + activeHandle, + activeHandleRef, + activeSessionTabTypeRef, + canSend, + client, + clientRef, + connState, + connStateRef, + deviceTokenRef, + flushPendingLiveInputBeforeExternalSend, + getActiveWorktreeConnectionId, + onError, + onSuccess, + ptyModesRef, + refreshCanPaste, + showToast +}: UseMobileTerminalPasteOptions): () => Promise { + return useCallback(async () => { + if (!client || !activeHandle || !canSend) { + return + } + const targetHandle = activeHandle + try { + const text = await Clipboard.getStringAsync() + let payload: string | null = null + if (text.length > 0) { + payload = buildMobileTerminalClipboardTextPayload( + text, + ptyModesRef.current.get(targetHandle) + ) + } else { + const image = await Clipboard.getImageAsync({ format: 'png' }) + if (!image) { + refreshCanPaste() + return + } + const connectionId = await getActiveWorktreeConnectionId() + const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage) + const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, { + connectionId + }) + payload = buildMobileImagePastePayload(imagePath) + } + + const wrappedBytes = new TextEncoder().encode(payload).byteLength + if (wrappedBytes > 256 * 1024) { + onError() + // eslint-disable-next-line no-console + console.warn('[mobile-clip] paste oversized', { wrappedBytes }) + showToast('Paste too large (max 256 KiB)', 1500) + return + } + // Why: paste lives in the accessory row and must not overtake pending IME text. + const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) + if (!flushedPendingInput) { + return + } + const currentClient = clientRef.current + if ( + !currentClient || + connStateRef.current !== 'connected' || + targetHandle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return + } + await currentClient.sendRequest('terminal.send', { + terminal: targetHandle, + text: payload, + enter: false, + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }) + onSuccess() + refreshCanPaste() + } catch (e) { + onError() + const err = e as { name?: string; message?: string } + const isDisconnected = connState !== 'connected' + // eslint-disable-next-line no-console + console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message }) + if (isDisconnected) { + showToast('Paste failed (disconnected)', 1500) + } else if (err.message === 'Clipboard image is too large') { + showToast('Image too large to paste', 1500) + } else { + showToast('Paste failed', 1500) + } + } + }, [ + activeHandle, + activeHandleRef, + activeSessionTabTypeRef, + canSend, + client, + clientRef, + connState, + connStateRef, + deviceTokenRef, + flushPendingLiveInputBeforeExternalSend, + getActiveWorktreeConnectionId, + onError, + onSuccess, + ptyModesRef, + refreshCanPaste, + showToast + ]) +} diff --git a/mobile/src/terminal/terminal-live-accessory-input.ts b/mobile/src/terminal/terminal-live-accessory-input.ts new file mode 100644 index 00000000000..5f483ebc0cf --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-input.ts @@ -0,0 +1,21 @@ +import type { TerminalLiveAccessoryLocalEdit } from './terminal-live-text-commit' + +export type TerminalLiveAccessoryInput = { + readonly bytes: string + readonly localEdit?: TerminalLiveAccessoryLocalEdit +} + +type TerminalLiveAccessoryKey = { + readonly bytes: string + readonly id: string +} + +export function createTerminalLiveAccessoryInput( + key: TerminalLiveAccessoryKey +): TerminalLiveAccessoryInput { + if (key.id === 'backspace' || key.id === 'delete') { + return { bytes: key.bytes, localEdit: key.id } + } + + return { bytes: key.bytes } +} diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts new file mode 100644 index 00000000000..5e484c354bc --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target' + +describe('terminal live accessory raw send target', () => { + it('Given the original terminal is still active When raw fallback resumes Then returns that handle', () => { + // Given + const targetHandle = 'terminal-a' + + // When + const sendTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: targetHandle, + activeSessionTabType: 'terminal' + }) + + // Then + expect(sendTarget).toBe(targetHandle) + }) + + it('Given the active terminal changed while waiting When raw fallback resumes Then suppresses the send', () => { + // Given + const targetHandle = 'terminal-a' + + // When + const sendTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: 'terminal-b', + activeSessionTabType: 'terminal' + }) + + // Then + expect(sendTarget).toBeNull() + }) + + it('Given the target is not an active terminal tab When raw fallback resumes Then suppresses the send', () => { + // Given + const targetHandle = 'terminal-a' + + // When + const inactiveTabTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: targetHandle, + activeSessionTabType: 'browser' + }) + + // Then + expect(inactiveTabTarget).toBeNull() + }) +}) diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts b/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts new file mode 100644 index 00000000000..f87138d210e --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts @@ -0,0 +1,17 @@ +type TerminalLiveAccessoryRawSendTargetInput = { + readonly targetHandle: string + readonly activeHandle: string | null + readonly activeSessionTabType: TTabType | null +} + +export function getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle, + activeSessionTabType +}: TerminalLiveAccessoryRawSendTargetInput): string | null { + if (targetHandle !== activeHandle || activeSessionTabType !== 'terminal') { + return null + } + + return targetHandle +} diff --git a/mobile/src/terminal/terminal-live-control-send-order.test.ts b/mobile/src/terminal/terminal-live-control-send-order.test.ts new file mode 100644 index 00000000000..8f1797c7ca0 --- /dev/null +++ b/mobile/src/terminal/terminal-live-control-send-order.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' + +describe('terminal live control send order', () => { + it('Given a failed pending text flush When control bytes follow Then skips the control bytes', async () => { + // Given + const events: string[] = [] + const flushPendingText = vi.fn(async () => { + events.push('flush') + return false + }) + const sendControlBytes = vi.fn(async () => { + events.push('control') + return true + }) + + // When + const result = await sendTerminalLiveControlAfterPendingFlush( + flushPendingText, + sendControlBytes + ) + + // Then + expect(result).toBe(false) + expect(sendControlBytes).not.toHaveBeenCalled() + expect(events).toEqual(['flush']) + }) + + it('Given a successful pending text flush When control bytes follow Then sends them afterward', async () => { + // Given + const events: string[] = [] + const flushPendingText = vi.fn(async () => { + events.push('flush') + return true + }) + const sendControlBytes = vi.fn(async () => { + events.push('control') + return true + }) + + // When + const result = await sendTerminalLiveControlAfterPendingFlush( + flushPendingText, + sendControlBytes + ) + + // Then + expect(result).toBe(true) + expect(events).toEqual(['flush', 'control']) + }) + + it('Given a failed control byte send When pending text flushed Then reports failure', async () => { + // Given + const events: string[] = [] + const flushPendingText = vi.fn(async () => { + events.push('flush') + return true + }) + const sendControlBytes = vi.fn(async () => { + events.push('control') + return false + }) + + // When + const result = await sendTerminalLiveControlAfterPendingFlush( + flushPendingText, + sendControlBytes + ) + + // Then + expect(result).toBe(false) + expect(events).toEqual(['flush', 'control']) + }) +}) diff --git a/mobile/src/terminal/terminal-live-control-send-order.ts b/mobile/src/terminal/terminal-live-control-send-order.ts new file mode 100644 index 00000000000..185b49ee48e --- /dev/null +++ b/mobile/src/terminal/terminal-live-control-send-order.ts @@ -0,0 +1,12 @@ +export type TerminalLiveAsyncSendStep = () => Promise + +export async function sendTerminalLiveControlAfterPendingFlush( + flushPendingText: TerminalLiveAsyncSendStep, + sendControlBytes: TerminalLiveAsyncSendStep +): Promise { + const flushed = await flushPendingText() + if (!flushed) { + return false + } + return sendControlBytes() +} diff --git a/mobile/src/terminal/terminal-live-input-sender.ts b/mobile/src/terminal/terminal-live-input-sender.ts new file mode 100644 index 00000000000..c3538d1f723 --- /dev/null +++ b/mobile/src/terminal/terminal-live-input-sender.ts @@ -0,0 +1 @@ +export type TerminalLiveInputSender = (handle: string, bytes: string) => Promise diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts new file mode 100644 index 00000000000..27dd4147d09 --- /dev/null +++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' +import { + queueTerminalLivePendingFlush, + waitForTerminalLivePendingFlush, + type TerminalLivePendingFlushState +} from './terminal-live-pending-flush-state' + +describe('terminal live pending flush state', () => { + it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => { + // Given + const state: TerminalLivePendingFlushState = { current: null } + + // When / Then + await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true) + }) + + it('Given an in-flight flush When control input waits Then control is held until flush succeeds', async () => { + // Given + const events: string[] = [] + let resolveFlush: (value: boolean) => void = () => {} + const flushPromise = new Promise((resolve) => { + resolveFlush = resolve + }) + const state: TerminalLivePendingFlushState = { current: flushPromise } + + // When + const controlSend = sendTerminalLiveControlAfterPendingFlush( + () => waitForTerminalLivePendingFlush(state), + async () => { + events.push('control') + return true + } + ) + await Promise.resolve() + + // Then + expect(events).toEqual([]) + resolveFlush(true) + await expect(controlSend).resolves.toBe(true) + expect(events).toEqual(['control']) + }) + + it('Given an in-flight flush fails When control input waits Then control is skipped', async () => { + // Given + const events: string[] = [] + let resolveFlush: (value: boolean) => void = () => {} + const flushPromise = new Promise((resolve) => { + resolveFlush = resolve + }) + const state: TerminalLivePendingFlushState = { current: flushPromise } + + // When + const controlSend = sendTerminalLiveControlAfterPendingFlush( + () => waitForTerminalLivePendingFlush(state), + async () => { + events.push('control') + return true + } + ) + resolveFlush(false) + + // Then + await expect(controlSend).resolves.toBe(false) + expect(events).toEqual([]) + }) + + it('Given a queued pending flush When it resolves or rejects Then clears the barrier', async () => { + // Given + const resolvedState: TerminalLivePendingFlushState = { current: null } + const rejectedState: TerminalLivePendingFlushState = { current: null } + + // When + await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true) + await expect( + queueTerminalLivePendingFlush(rejectedState, async () => { + throw new Error('send failed') + }) + ).resolves.toBe(false) + await Promise.resolve() + + // Then + expect(resolvedState.current).toBeNull() + expect(rejectedState.current).toBeNull() + }) + + it('Given a current pending snapshot while another flush is in flight When queued Then sends current text after prior success', async () => { + // Given + const events: string[] = [] + let resolveFirstFlush: (value: boolean) => void = () => {} + const firstFlush = new Promise((resolve) => { + resolveFirstFlush = resolve + }) + const state: TerminalLivePendingFlushState = { current: firstFlush } + + // When + const secondFlush = queueTerminalLivePendingFlush(state, async () => { + events.push('second-flush') + return true + }) + const controlSend = sendTerminalLiveControlAfterPendingFlush( + () => waitForTerminalLivePendingFlush(state), + async () => { + events.push('control') + return true + } + ) + await Promise.resolve() + + // Then + expect(events).toEqual([]) + resolveFirstFlush(true) + await expect(secondFlush).resolves.toBe(true) + await expect(controlSend).resolves.toBe(true) + expect(events).toEqual(['second-flush', 'control']) + }) + + it('Given a prior pending flush fails When another snapshot is queued Then skips the current text and control', async () => { + // Given + const events: string[] = [] + let resolveFirstFlush: (value: boolean) => void = () => {} + const firstFlush = new Promise((resolve) => { + resolveFirstFlush = resolve + }) + const state: TerminalLivePendingFlushState = { current: firstFlush } + + // When + const secondFlush = queueTerminalLivePendingFlush(state, async () => { + events.push('second-flush') + return true + }) + const controlSend = sendTerminalLiveControlAfterPendingFlush( + () => waitForTerminalLivePendingFlush(state), + async () => { + events.push('control') + return true + } + ) + resolveFirstFlush(false) + + // Then + await expect(secondFlush).resolves.toBe(false) + await expect(controlSend).resolves.toBe(false) + expect(events).toEqual([]) + }) +}) diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts new file mode 100644 index 00000000000..ea760fcee54 --- /dev/null +++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts @@ -0,0 +1,29 @@ +export type TerminalLivePendingFlushState = { + current: Promise | null +} + +export function waitForTerminalLivePendingFlush( + state: TerminalLivePendingFlushState +): Promise { + return state.current ?? Promise.resolve(true) +} + +export function queueTerminalLivePendingFlush( + state: TerminalLivePendingFlushState, + sendPendingText: () => Promise +): Promise { + const previousFlush = state.current + const flushPromise = (async () => { + if (previousFlush && !(await previousFlush)) { + return false + } + return sendPendingText() + })().catch(() => false) + state.current = flushPromise + void flushPromise.then(() => { + if (state.current === flushPromise) { + state.current = null + } + }) + return flushPromise +} diff --git a/mobile/src/terminal/terminal-live-text-commit.test.ts b/mobile/src/terminal/terminal-live-text-commit.test.ts new file mode 100644 index 00000000000..00435fb2230 --- /dev/null +++ b/mobile/src/terminal/terminal-live-text-commit.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS, + getTerminalLiveAccessoryBytesDecision, + getTerminalLiveAccessoryLocalEditText, + getTerminalLiveDeferredTextDelayMs, + getTerminalLiveSpecialKeyDecision, + getTerminalLiveSubmitSequence, + getTerminalLiveTextChangeDecision, + isTerminalLiveTextHangulCandidate, + isTerminalLiveTextImeCandidate +} from './terminal-live-text-commit' + +describe('terminal live text commit', () => { + it('Given Korean IME changes When live text changes Then defers candidates and submits only final text before carriage return', () => { + // Given + const koreanCompositionSteps = ['ㅎ', '하', '한'] as const + + // When + const decisions = koreanCompositionSteps.map(getTerminalLiveTextChangeDecision) + const sentImmediately = decisions.filter((decision) => decision.kind === 'send-now') + const submitSequence = getTerminalLiveSubmitSequence('한') + const composedWordDecision = getTerminalLiveTextChangeDecision('한글') + const composedWordSubmitSequence = getTerminalLiveSubmitSequence('한글') + + // Then + expect(decisions).toEqual([ + { kind: 'defer', text: 'ㅎ', delayMs: null }, + { kind: 'defer', text: '하', delayMs: null }, + { kind: 'defer', text: '한', delayMs: null } + ]) + expect(sentImmediately).toEqual([]) + expect(isTerminalLiveTextHangulCandidate('ㅎ')).toBe(true) + expect(getTerminalLiveDeferredTextDelayMs('ㅎ')).toBeNull() + expect(submitSequence).toEqual(['한', '\r']) + expect(composedWordDecision).toEqual({ + kind: 'defer', + text: '한글', + delayMs: null + }) + expect(composedWordSubmitSequence).toEqual(['한글', '\r']) + }) + + it('Given non-Hangul IME text When live text changes Then keeps the bounded settle timer', () => { + // Given + const text = 'あ' + + // When + const decision = getTerminalLiveTextChangeDecision(text) + + // Then + expect(isTerminalLiveTextImeCandidate(text)).toBe(true) + expect(isTerminalLiveTextHangulCandidate(text)).toBe(false) + expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS) + expect(decision).toEqual({ kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS }) + }) + + it('Given Chinese and Vietnamese IME text When live text changes Then does not use Hangul-only indefinite deferral', () => { + // Given + const nonHangulImeTexts = ['你好', 'tiếng Việt'] as const + + for (const text of nonHangulImeTexts) { + // When + const decision = getTerminalLiveTextChangeDecision(text) + + // Then + expect(isTerminalLiveTextImeCandidate(text)).toBe(true) + expect(isTerminalLiveTextHangulCandidate(text)).toBe(false) + expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS) + expect(decision).toEqual({ + kind: 'defer', + text, + delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS + }) + } + }) + + it('Given ASCII text When live text changes Then sends immediately', () => { + // Given + const text = 'abc123' + + // When + const decision = getTerminalLiveTextChangeDecision(text) + + // Then + expect(isTerminalLiveTextImeCandidate(text)).toBe(false) + expect(decision).toEqual({ kind: 'send-now', text }) + }) + + it('Given empty text When live text changes Then ignores the change', () => { + // Given + const text = '' + + // When + const decision = getTerminalLiveTextChangeDecision(text) + + // Then + expect(isTerminalLiveTextImeCandidate(text)).toBe(false) + expect(decision).toEqual({ kind: 'ignore' }) + }) + + it('Given pending text When Backspace or Delete is pressed Then keeps edits local', () => { + // Given + const pendingText = '한' + + // When + const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText }) + const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText }) + + // Then + expect(backspaceDecision).toEqual({ kind: 'local-edit' }) + expect(deleteDecision).toEqual({ kind: 'local-edit' }) + }) + + it('Given no pending text When Backspace or Delete is pressed Then sends terminal bytes', () => { + // Given + const pendingText = '' + + // When + const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText }) + const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText }) + + // Then + expect(backspaceDecision).toEqual({ kind: 'send-now', bytes: '\x7f' }) + expect(deleteDecision).toEqual({ kind: 'send-now', bytes: '\x1b[3~' }) + }) + + it('Given pending text When a terminal special key is pressed Then flushes pending text before bytes', () => { + // Given + const pendingText = '한' + + // When + const decision = getTerminalLiveSpecialKeyDecision({ key: 'Tab', pendingText }) + + // Then + expect(decision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' }) + }) + + it('Given pending text When accessory control bytes are requested Then flushes pending text before bytes', () => { + // Given + const pendingText = '한글' + + // When + const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText }) + const escapeDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\x1b', pendingText }) + const enterDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\r', pendingText }) + + // Then + expect(tabDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' }) + expect(escapeDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\x1b' }) + expect(enterDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\r' }) + }) + + it('Given pending text When accessory Backspace or Delete bytes are requested Then keeps edits local', () => { + // Given + const pendingText = '한글' + + // When + const backspaceDecision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + localEdit: 'backspace', + pendingText + }) + const ctrlBackspaceDecision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\b', + localEdit: 'backspace', + pendingText + }) + const deleteDecision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x1b[3~', + localEdit: 'delete', + pendingText + }) + const customDeleteByteDecision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + pendingText + }) + const backspaceText = getTerminalLiveAccessoryLocalEditText({ + localEdit: 'backspace', + pendingText + }) + const deleteText = getTerminalLiveAccessoryLocalEditText({ + localEdit: 'delete', + pendingText + }) + + // Then + expect(backspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' }) + expect(ctrlBackspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' }) + expect(deleteDecision).toEqual({ kind: 'local-edit', localEdit: 'delete' }) + expect(customDeleteByteDecision).toEqual({ + kind: 'flush-then-send', + pendingText, + bytes: '\x7f' + }) + expect(backspaceText).toBe('한') + expect(deleteText).toBe('한글') + expect(getTerminalLiveDeferredTextDelayMs(backspaceText)).toBeNull() + expect(getTerminalLiveDeferredTextDelayMs(deleteText)).toBeNull() + }) + + it('Given no pending text When accessory bytes are requested Then sends terminal bytes', () => { + // Given + const pendingText = '' + + // When + const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText }) + + // Then + expect(tabDecision).toEqual({ kind: 'send-now', bytes: '\t' }) + }) + + it('Given a non-special key When key decision is requested Then ignores it', () => { + // Given + const key = 'a' + + // When + const decision = getTerminalLiveSpecialKeyDecision({ key, pendingText: '한' }) + + // Then + expect(decision).toEqual({ kind: 'ignore' }) + }) + + it('Given no pending text When submit is requested Then sends only carriage return', () => { + // Given + const pendingText = '' + + // When + const sequence = getTerminalLiveSubmitSequence(pendingText) + + // Then + expect(sequence).toEqual(['\r']) + }) +}) diff --git a/mobile/src/terminal/terminal-live-text-commit.ts b/mobile/src/terminal/terminal-live-text-commit.ts new file mode 100644 index 00000000000..aac74b8931b --- /dev/null +++ b/mobile/src/terminal/terminal-live-text-commit.ts @@ -0,0 +1,145 @@ +import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input' + +// Why: React Native does not expose portable composition events here, so +// non-Hangul IME text gets a short settle window before being sent to the PTY. +export const TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS = 150 + +export type TerminalLiveTextChangeDecision = + | { readonly kind: 'ignore' } + | { readonly kind: 'send-now'; readonly text: string } + | { readonly kind: 'defer'; readonly text: string; readonly delayMs: number | null } + +export type TerminalLiveSpecialKeyDecision = + | { readonly kind: 'ignore' } + | { readonly kind: 'local-edit' } + | { readonly kind: 'send-now'; readonly bytes: string } + | { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string } + +export type TerminalLiveSpecialKeyDecisionInput = { + readonly key: string + readonly pendingText: string +} + +export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete' + +export type TerminalLiveAccessoryBytesDecision = + | { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit } + | { readonly kind: 'send-now'; readonly bytes: string } + | { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string } + +export type TerminalLiveAccessoryBytesDecisionInput = { + readonly bytes: string + readonly localEdit?: TerminalLiveAccessoryLocalEdit + readonly pendingText: string +} + +export function isTerminalLiveTextImeCandidate(text: string): boolean { + for (const character of text) { + const codePoint = character.codePointAt(0) + if (codePoint !== undefined && codePoint > 0x7f) { + return true + } + } + return false +} + +function isHangulCodePoint(codePoint: number): boolean { + return ( + (codePoint >= 0x1100 && codePoint <= 0x11ff) || + (codePoint >= 0x3130 && codePoint <= 0x318f) || + (codePoint >= 0xa960 && codePoint <= 0xa97f) || + (codePoint >= 0xac00 && codePoint <= 0xd7af) + ) +} + +export function isTerminalLiveTextHangulCandidate(text: string): boolean { + for (const character of text) { + const codePoint = character.codePointAt(0) + if (codePoint !== undefined && isHangulCodePoint(codePoint)) { + return true + } + } + return false +} + +export function getTerminalLiveTextChangeDecision(text: string): TerminalLiveTextChangeDecision { + if (text.length === 0) { + return { kind: 'ignore' } + } + + if (isTerminalLiveTextHangulCandidate(text)) { + return { kind: 'defer', text, delayMs: null } + } + + if (isTerminalLiveTextImeCandidate(text)) { + return { kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } + } + + return { kind: 'send-now', text } +} + +export function getTerminalLiveDeferredTextDelayMs(text: string): number | null { + return isTerminalLiveTextHangulCandidate(text) ? null : TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS +} + +export function getTerminalLiveSpecialKeyDecision({ + key, + pendingText +}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision { + const bytes = getTerminalLiveSpecialKeyBytes(key) + if (bytes === null) { + return { kind: 'ignore' } + } + + if (pendingText.length > 0 && (key === 'Backspace' || key === 'Delete')) { + return { kind: 'local-edit' } + } + + if (pendingText.length > 0) { + return { kind: 'flush-then-send', pendingText, bytes } + } + + return { kind: 'send-now', bytes } +} + +export function getTerminalLiveAccessoryBytesDecision({ + bytes, + localEdit, + pendingText +}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision { + if (pendingText.length > 0 && localEdit) { + return { kind: 'local-edit', localEdit } + } + + if (pendingText.length > 0) { + return { kind: 'flush-then-send', pendingText, bytes } + } + + return { kind: 'send-now', bytes } +} + +export function getTerminalLiveAccessoryLocalEditText({ + localEdit, + pendingText +}: { + readonly localEdit: TerminalLiveAccessoryLocalEdit + readonly pendingText: string +}): string { + if (localEdit === 'delete') { + // Why: accessory Delete mirrors forward-delete at the hidden input's end; + // it stays local but does not remove the pending IME text. + return pendingText + } + + return Array.from(pendingText).slice(0, -1).join('') +} + +export type TerminalLiveSubmitSequence = readonly ['\r'] | readonly [string, '\r'] + +export function getTerminalLiveSubmitSequence(pendingText: string): TerminalLiveSubmitSequence { + if (pendingText.length === 0) { + return ['\r'] + } + + return [pendingText, '\r'] +} diff --git a/mobile/src/terminal/terminal-send-rpc-response.test.ts b/mobile/src/terminal/terminal-send-rpc-response.test.ts new file mode 100644 index 00000000000..5ef459a6fde --- /dev/null +++ b/mobile/src/terminal/terminal-send-rpc-response.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { RpcResponse } from '../transport/types' +import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response' + +const runtimeMeta = { runtimeId: 'test-runtime' } as const + +describe('terminal send RPC response', () => { + it('Given accepted terminal send response When checked Then reports success', () => { + // Given + const response: RpcResponse = { + id: '1', + ok: true, + result: { send: { handle: 'terminal-1', accepted: true, bytesWritten: 1 } }, + _meta: runtimeMeta + } + + // When / Then + expect(isTerminalSendRpcAccepted(response)).toBe(true) + }) + + it('Given rejected terminal send response When checked Then reports failure', () => { + // Given + const response: RpcResponse = { + id: '1', + ok: true, + result: { send: { handle: 'terminal-1', accepted: false, bytesWritten: 0 } }, + _meta: runtimeMeta + } + + // When / Then + expect(isTerminalSendRpcAccepted(response)).toBe(false) + }) + + it('Given RPC failure or malformed terminal send response When checked Then reports failure', () => { + // Given + const rpcFailure: RpcResponse = { + id: '1', + ok: false, + error: { code: 'terminal_error', message: 'failed' }, + _meta: runtimeMeta + } + const malformedSuccess: RpcResponse = { + id: '2', + ok: true, + result: {}, + _meta: runtimeMeta + } + + // When / Then + expect(isTerminalSendRpcAccepted(rpcFailure)).toBe(false) + expect(isTerminalSendRpcAccepted(malformedSuccess)).toBe(false) + }) +}) diff --git a/mobile/src/terminal/terminal-send-rpc-response.ts b/mobile/src/terminal/terminal-send-rpc-response.ts new file mode 100644 index 00000000000..454c5e32972 --- /dev/null +++ b/mobile/src/terminal/terminal-send-rpc-response.ts @@ -0,0 +1,15 @@ +import type { RpcResponse } from '../transport/types' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function isTerminalSendRpcAccepted(response: RpcResponse): boolean { + if (!response.ok) { + return false + } + if (!isRecord(response.result) || !isRecord(response.result.send)) { + return false + } + return response.result.send.accepted === true +} diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts new file mode 100644 index 00000000000..b6bcb380607 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit' + +type DeferredBoolean = { + readonly promise: Promise + readonly resolve: (value: boolean) => void +} + +function createDeferredBoolean(): DeferredBoolean { + let resolvePromise: (value: boolean) => void = () => { + throw new Error('deferred promise was resolved before initialization') + } + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + return { promise, resolve: resolvePromise } +} + +describe('terminal live accessory input commit', () => { + it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => { + // Given + const deferredFlush = createDeferredBoolean() + let settled = false + + // When + const resultPromise = getTerminalLiveAccessoryInactiveInputCommitResult( + () => deferredFlush.promise + ) + void resultPromise.then(() => { + settled = true + }) + await Promise.resolve() + + // Then + expect(settled).toBe(false) + deferredFlush.resolve(true) + await expect(resultPromise).resolves.toEqual({ kind: 'allow-raw' }) + }) + + it('Given live input is disabled with a failed active flush When accessory raw fallback is requested Then suppresses raw send', async () => { + // Given + const waitForPendingLiveInputFlush = async (): Promise => false + + // When + const result = await getTerminalLiveAccessoryInactiveInputCommitResult( + waitForPendingLiveInputFlush + ) + + // Then + expect(result).toEqual({ kind: 'suppress-raw' }) + }) +}) diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts new file mode 100644 index 00000000000..b7e8cc8b214 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts @@ -0,0 +1,124 @@ +import { useCallback, type RefObject } from 'react' +import type { TextInput } from 'react-native' +import { + getTerminalLiveAccessoryBytesDecision, + getTerminalLiveAccessoryLocalEditText, + getTerminalLiveDeferredTextDelayMs +} from './terminal-live-text-commit' +import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' +import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' +import type { TerminalLiveInputSender } from './terminal-live-input-sender' + +export type TerminalLiveAccessoryInputCommitResult = + | { readonly kind: 'allow-raw' } + | { readonly kind: 'handled' } + | { readonly kind: 'suppress-raw' } + +type TerminalLiveInputCommitScheduler = ( + handle: string, + text: string, + delayMs: number | null +) => void + +export async function getTerminalLiveAccessoryInactiveInputCommitResult( + waitForPendingLiveInputFlush: () => Promise +): Promise { + return (await waitForPendingLiveInputFlush()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' } +} + +type TerminalLiveAccessoryInputCommitOptions = { + readonly activeHandle: string | null + readonly clearPendingLiveInputCommit: () => void + readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise + readonly liveInputRef: RefObject + readonly liveInputTerminalHandles: ReadonlySet + readonly pendingLiveInputHandleRef: RefObject + readonly pendingLiveInputTextRef: RefObject + readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler + readonly sendLiveTerminalInputRef: RefObject + readonly setLiveInputCapture: (text: string) => void + readonly waitForPendingLiveInputFlush: () => Promise +} + +export function useTerminalLiveAccessoryInputCommit({ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputRef, + liveInputTerminalHandles, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + sendLiveTerminalInputRef, + setLiveInputCapture, + waitForPendingLiveInputFlush +}: TerminalLiveAccessoryInputCommitOptions): ( + input: TerminalLiveAccessoryInput +) => Promise { + return useCallback( + async (input: TerminalLiveAccessoryInput): Promise => { + if (!activeHandle) { + return { kind: 'allow-raw' } + } + if (!liveInputTerminalHandles.has(activeHandle)) { + return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush) + } + const pendingText = + pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' + if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) { + clearPendingLiveInputCommit() + } + const decision = getTerminalLiveAccessoryBytesDecision({ ...input, pendingText }) + switch (decision.kind) { + case 'send-now': + // Why: raw accessory bytes must wait behind any in-flight IME text + // flush so composed Hangul reaches the PTY before follow-up controls. + return (await waitForPendingLiveInputFlush()) + ? { kind: 'allow-raw' } + : { kind: 'suppress-raw' } + case 'local-edit': { + const editedText = getTerminalLiveAccessoryLocalEditText({ + localEdit: decision.localEdit, + pendingText + }) + if (editedText.length === 0) { + clearPendingLiveInputCommit() + return { kind: 'handled' } + } + // Why: accessory buttons do not emit native TextInput edits, so the + // pending IME buffer must be edited and rescheduled here. + setLiveInputCapture(editedText) + liveInputRef.current?.setNativeProps({ text: editedText }) + schedulePendingLiveInputCommit( + activeHandle, + editedText, + getTerminalLiveDeferredTextDelayMs(editedText) + ) + return { kind: 'handled' } + } + case 'flush-then-send': + await sendTerminalLiveControlAfterPendingFlush( + () => flushPendingLiveInputText(activeHandle), + () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) + ) + return { kind: 'handled' } + default: + decision satisfies never + return { kind: 'handled' } + } + }, + [ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputRef, + liveInputTerminalHandles, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + sendLiveTerminalInputRef, + setLiveInputCapture, + waitForPendingLiveInputFlush + ] + ) +} diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts new file mode 100644 index 00000000000..005d4b77f15 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -0,0 +1,184 @@ +import { createElement, type RefObject } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import type { TextInput } from 'react-native' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalLiveInputSender } from './terminal-live-input-sender' +import { TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } from './terminal-live-text-commit' +import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit' + +type TerminalLiveInputCommitHarness = { + readonly captures: readonly string[] + readonly handlers: ReturnType> + readonly sent: readonly string[] + readonly unmount: () => void +} + +type TerminalLiveInputCommitHarnessOptions = { + readonly sendResult?: boolean +} + +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 createTerminalLiveInputCommitHarness({ + sendResult = true +}: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness { + const activeHandle = 'terminal-a' + const activeHandleRef: RefObject = { current: activeHandle } + const activeSessionTabTypeRef: RefObject = { current: 'terminal' } + const captures: string[] = [] + const liveInputRef: RefObject = { current: null } + const liveInputTerminalHandles = new Set([activeHandle]) + const liveInputTerminalHandlesRef: RefObject> = { + current: new Set([activeHandle]) + } + const sent: string[] = [] + const sendLiveTerminalInputRef: RefObject = { + current: async (_handle, bytes) => { + sent.push(bytes) + return sendResult + } + } + let handlers: ReturnType> | null = null + let renderer: ReactTestRenderer | null = null + + function Harness(): null { + handlers = useTerminalLiveInputCommit({ + activeHandle, + activeHandleRef, + activeSessionTabType: 'terminal', + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture: (text) => captures.push(text) + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(Harness)) + }) + } finally { + restoreConsoleError() + } + if (!handlers || !renderer) { + throw new Error('terminal live input hook did not render') + } + + return { + captures, + handlers, + sent, + unmount: () => { + act(() => renderer?.unmount()) + } + } +} + +describe('terminal live input commit hook', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('Given Hangul pending text When the old idle window elapses Then does not send jamo to the terminal', async () => { + // Given + vi.useFakeTimers() + const { captures, handlers, sent } = createTerminalLiveInputCommitHarness() + + // When + handlers.handleLiveInputChange('ㅎ') + await vi.advanceTimersByTimeAsync(1_000) + + // Then + expect(captures).toEqual(['ㅎ']) + expect(sent).toEqual([]) + }) + + it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('한') + + // When + handlers.handleLiveInputSubmit() + + // Then + await vi.waitFor(() => expect(sent).toEqual(['한', '\r'])) + }) + + it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('한') + + // When + const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a') + + // Then + expect(flushed).toBe(true) + expect(sent).toEqual(['한']) + }) + + it('Given pending text cannot be sent When an external terminal send is requested Then reports failure', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false }) + handlers.handleLiveInputChange('한') + + // When + const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a') + + // Then + expect(flushed).toBe(false) + expect(sent).toEqual(['한']) + }) + + it('Given Chinese and Vietnamese IME text When the settle window elapses Then sends the committed text', async () => { + // Given + vi.useFakeTimers() + const { captures, handlers, sent } = createTerminalLiveInputCommitHarness() + + // When + handlers.handleLiveInputChange('你好') + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - 1) + + // Then + expect(captures).toEqual(['你好']) + expect(sent).toEqual([]) + + // When + await vi.advanceTimersByTimeAsync(1) + await vi.waitFor(() => expect(sent).toEqual(['你好'])) + handlers.handleLiveInputChange('tiếng Việt') + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS) + + // Then + await vi.waitFor(() => expect(sent).toEqual(['你好', 'tiếng Việt'])) + }) + + it('Given deferred IME text When the hook unmounts Then cancels the pending commit timer', async () => { + // Given + vi.useFakeTimers() + const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('é') + + // When + unmount() + await vi.advanceTimersByTimeAsync(1_000) + + // Then + expect(sent).toEqual([]) + }) +}) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts new file mode 100644 index 00000000000..2e2dd9e1589 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -0,0 +1,236 @@ +import { useCallback, useEffect, type RefObject } from 'react' +import type { TextInput } from 'react-native' +import { + getTerminalLiveSpecialKeyDecision, + getTerminalLiveSubmitSequence, + getTerminalLiveTextChangeDecision +} from './terminal-live-text-commit' +import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' +import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' +import type { TerminalLiveInputSender } from './terminal-live-input-sender' +import { normalizeTerminalTextInput } from './terminal-text-input-normalization' +import { useTerminalLivePendingInputFlush } from './use-terminal-live-pending-input-flush' +import { + useTerminalLiveAccessoryInputCommit, + type TerminalLiveAccessoryInputCommitResult +} from './use-terminal-live-accessory-input-commit' + +type TerminalLiveInputKeyPressEvent = { + readonly nativeEvent: { + readonly key: string + } +} + +type TerminalLiveInputCommitOptions = { + readonly activeHandle: string | null + readonly activeHandleRef: RefObject + readonly activeSessionTabType: TTabType | null | undefined + readonly activeSessionTabTypeRef: RefObject + readonly liveInputRef: RefObject + readonly liveInputTerminalHandles: ReadonlySet + readonly liveInputTerminalHandlesRef: RefObject> + readonly sendLiveTerminalInputRef: RefObject + readonly setLiveInputCapture: (text: string) => void +} + +type TerminalLiveInputCommitHandlers = { + readonly clearPendingLiveInputCommit: () => void + readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly handleLiveInputAccessoryBytes: ( + input: TerminalLiveAccessoryInput + ) => Promise + readonly handleLiveInputChange: (text: string) => void + readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void + readonly handleLiveInputSubmit: () => void +} + +export function useTerminalLiveInputCommit({ + activeHandle, + activeHandleRef, + activeSessionTabType, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture +}: TerminalLiveInputCommitOptions): TerminalLiveInputCommitHandlers { + const { + clearPendingLiveInputCommit, + flushPendingLiveInputText, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + waitForPendingLiveInputFlush + } = useTerminalLivePendingInputFlush({ + activeHandleRef, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture + }) + + useEffect(() => { + const pendingHandle = pendingLiveInputHandleRef.current + if (!pendingHandle) { + return + } + if ( + !activeHandle || + pendingHandle !== activeHandle || + activeSessionTabType !== 'terminal' || + !liveInputTerminalHandles.has(activeHandle) + ) { + clearPendingLiveInputCommit() + } + }, [activeHandle, activeSessionTabType, clearPendingLiveInputCommit, liveInputTerminalHandles]) + + const flushPendingLiveInputBeforeExternalSend = useCallback( + async (handle: string): Promise => { + const pendingHandle = pendingLiveInputHandleRef.current + if (pendingHandle && pendingHandle !== handle) { + clearPendingLiveInputCommit() + return waitForPendingLiveInputFlush() + } + if (pendingHandle === handle && pendingLiveInputTextRef.current.length > 0) { + return flushPendingLiveInputText(handle) + } + return waitForPendingLiveInputFlush() + }, + [clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush] + ) + + const handleLiveInputChange = useCallback( + (text: string) => { + if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { + clearPendingLiveInputCommit() + return + } + const normalizedText = normalizeTerminalTextInput(text) + const decision = getTerminalLiveTextChangeDecision(normalizedText) + switch (decision.kind) { + case 'ignore': + clearPendingLiveInputCommit() + return + case 'send-now': + clearPendingLiveInputCommit() + void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () => + sendLiveTerminalInputRef.current(activeHandle, decision.text) + ) + return + case 'defer': + // Why: React Native does not expose composition events here, so keep + // probable IME text in the native field until the commit timer settles. + setLiveInputCapture(decision.text) + schedulePendingLiveInputCommit(activeHandle, decision.text, decision.delayMs) + return + default: + decision satisfies never + } + }, + [ + activeHandle, + clearPendingLiveInputCommit, + liveInputTerminalHandles, + schedulePendingLiveInputCommit, + sendLiveTerminalInputRef, + setLiveInputCapture, + waitForPendingLiveInputFlush + ] + ) + + const handleLiveInputKeyPress = useCallback( + (event: TerminalLiveInputKeyPressEvent) => { + if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { + return + } + const pendingText = + pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' + if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) { + clearPendingLiveInputCommit() + } + const decision = getTerminalLiveSpecialKeyDecision({ + key: event.nativeEvent.key, + pendingText + }) + switch (decision.kind) { + case 'ignore': + case 'local-edit': + return + case 'send-now': + clearPendingLiveInputCommit() + void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () => + sendLiveTerminalInputRef.current(activeHandle, decision.bytes) + ) + return + case 'flush-then-send': + void sendTerminalLiveControlAfterPendingFlush( + () => flushPendingLiveInputText(activeHandle), + () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) + ) + return + default: + decision satisfies never + } + }, + [ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputTerminalHandles, + sendLiveTerminalInputRef, + waitForPendingLiveInputFlush + ] + ) + + const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputRef, + liveInputTerminalHandles, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + sendLiveTerminalInputRef, + setLiveInputCapture, + waitForPendingLiveInputFlush + }) + + const handleLiveInputSubmit = useCallback(() => { + if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { + return + } + const pendingText = + pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' + const sequence = getTerminalLiveSubmitSequence(pendingText) + if (sequence.length === 2) { + void sendTerminalLiveControlAfterPendingFlush( + () => flushPendingLiveInputText(activeHandle), + () => sendLiveTerminalInputRef.current(activeHandle, sequence[1]) + ) + return + } + clearPendingLiveInputCommit() + void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () => + sendLiveTerminalInputRef.current(activeHandle, sequence[0]) + ) + }, [ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputTerminalHandles, + sendLiveTerminalInputRef, + waitForPendingLiveInputFlush + ]) + + return { + clearPendingLiveInputCommit, + flushPendingLiveInputBeforeExternalSend, + handleLiveInputAccessoryBytes, + handleLiveInputChange, + handleLiveInputKeyPress, + handleLiveInputSubmit + } +} diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts new file mode 100644 index 00000000000..2c6998c99a0 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef, type RefObject } from 'react' +import type { TextInput } from 'react-native' +import type { TerminalLiveInputSender } from './terminal-live-input-sender' +import { + queueTerminalLivePendingFlush, + waitForTerminalLivePendingFlush +} from './terminal-live-pending-flush-state' + +type TerminalLivePendingInputFlushOptions = { + readonly activeHandleRef: RefObject + readonly activeSessionTabTypeRef: RefObject + readonly liveInputRef: RefObject + readonly liveInputTerminalHandlesRef: RefObject> + readonly sendLiveTerminalInputRef: RefObject + readonly setLiveInputCapture: (text: string) => void +} + +type TerminalLivePendingInputFlush = { + readonly clearPendingLiveInputCommit: () => void + readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise + readonly pendingLiveInputHandleRef: RefObject + readonly pendingLiveInputTextRef: RefObject + readonly schedulePendingLiveInputCommit: ( + handle: string, + text: string, + delayMs: number | null + ) => void + readonly waitForPendingLiveInputFlush: () => Promise +} + +export function useTerminalLivePendingInputFlush({ + activeHandleRef, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture +}: TerminalLivePendingInputFlushOptions): TerminalLivePendingInputFlush { + const liveInputCommitTimerRef = useRef | null>(null) + const pendingLiveInputFlushRef = useRef | null>(null) + const pendingLiveInputTextRef = useRef('') + const pendingLiveInputHandleRef = useRef(null) + + const clearPendingLiveInputCommit = useCallback(() => { + if (liveInputCommitTimerRef.current) { + clearTimeout(liveInputCommitTimerRef.current) + liveInputCommitTimerRef.current = null + } + pendingLiveInputTextRef.current = '' + pendingLiveInputHandleRef.current = null + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + }, [liveInputRef, setLiveInputCapture]) + + const waitForPendingLiveInputFlush = useCallback(async (): Promise => { + return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef) + }, []) + + const flushPendingLiveInputText = useCallback( + async (expectedHandle: string | null): Promise => { + const existingFlush = pendingLiveInputFlushRef.current + if (liveInputCommitTimerRef.current) { + clearTimeout(liveInputCommitTimerRef.current) + liveInputCommitTimerRef.current = null + } + + const handle = pendingLiveInputHandleRef.current + const text = pendingLiveInputTextRef.current + pendingLiveInputHandleRef.current = null + pendingLiveInputTextRef.current = '' + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + + if (!handle || text.length === 0) { + return existingFlush ?? false + } + if ( + (expectedHandle !== null && handle !== expectedHandle) || + handle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' || + !liveInputTerminalHandlesRef.current.has(handle) + ) { + return false + } + + return queueTerminalLivePendingFlush(pendingLiveInputFlushRef, () => + sendLiveTerminalInputRef.current(handle, text) + ) + }, + [ + activeHandleRef, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture + ] + ) + + const schedulePendingLiveInputCommit = useCallback( + (handle: string, text: string, delayMs: number | null) => { + if (liveInputCommitTimerRef.current) { + clearTimeout(liveInputCommitTimerRef.current) + } + pendingLiveInputHandleRef.current = handle + pendingLiveInputTextRef.current = text + if (delayMs === null) { + liveInputCommitTimerRef.current = null + return + } + liveInputCommitTimerRef.current = setTimeout(() => { + liveInputCommitTimerRef.current = null + void flushPendingLiveInputText(handle) + }, delayMs) + }, + [flushPendingLiveInputText] + ) + + useEffect(() => { + return () => { + if (liveInputCommitTimerRef.current) { + clearTimeout(liveInputCommitTimerRef.current) + liveInputCommitTimerRef.current = null + } + pendingLiveInputHandleRef.current = null + pendingLiveInputTextRef.current = '' + pendingLiveInputFlushRef.current = null + } + }, []) + + return { + clearPendingLiveInputCommit, + flushPendingLiveInputText, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + waitForPendingLiveInputFlush + } +} diff --git a/src/renderer/src/hooks/terminal-command-finished-event.ts b/src/renderer/src/hooks/terminal-command-finished-event.ts index 79bf8a978a8..5a304b41846 100644 --- a/src/renderer/src/hooks/terminal-command-finished-event.ts +++ b/src/renderer/src/hooks/terminal-command-finished-event.ts @@ -8,6 +8,11 @@ export type TerminalCommandFinishedEventDetail = { // decoupled consumers (e.g. git status refresh) react to shell commands // finishing without reaching into terminal internals. export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void { + // Why: unit tests and non-DOM renderer shims may expose only the preload API. + if (typeof window.dispatchEvent !== 'function') { + return + } + window.dispatchEvent( new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { detail: { worktreeId } From 443d6efb50d17f7fb3b19cd597f4807f08675a58 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:34:10 -0700 Subject: [PATCH 03/70] Tune worktree card drag targets (#7095) --- .../src/components/sidebar/WorktreeList.tsx | 82 ++++++++++++--- .../sidebar/worktree-drag-preview-offsets.ts | 41 +++++++- .../worktree-lineage-drag-drop.test.ts | 99 +++++++++++++++++++ .../sidebar/worktree-lineage-drag-drop.ts | 68 +++++++++++++ .../sidebar/worktree-manual-order.test.ts | 58 +++++++++++ .../worktree-sidebar-drag-autoscroll.test.ts | 35 ++++++- .../worktree-sidebar-drag-autoscroll.ts | 7 +- .../worktree-sidebar-drop-preview.test.ts | 52 ++++++++++ .../sidebar/worktree-sidebar-drop-preview.ts | 2 + 9 files changed, 427 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 75cab5bcc2c..2296df9064b 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -180,6 +180,10 @@ import { type WorktreeSidebarTrackedStatusDropTarget, type WorktreeSidebarDropPreview } from './worktree-sidebar-drop-preview' +import { + getReorderedWorktreeIdsToUnnest, + getWorktreeLineageDropTargetId +} from './worktree-lineage-drag-drop' import { resolveProjectGroupHeaderColor } from './project-header-color' import { REPO_HEADER_ACTION_BUTTON_CLASS, @@ -984,11 +988,11 @@ function getPointerDropStatusTarget(args: { if (pinTarget && args.container.contains(pinTarget)) { return { status: null, isPinDrop: true, lineageParentId: null } } - const rowTarget = target.closest('[data-worktree-drag-id]') - let lineageParentId: string | null = null - if (rowTarget && args.container.contains(rowTarget)) { - lineageParentId = rowTarget.dataset.worktreeDragId ?? null - } + const lineageParentId = getWorktreeLineageDropTargetId({ + container: args.container, + target, + pointerY: args.y + }) const statusTarget = target.closest('[data-workspace-status-drop-target]') return { status: @@ -1314,6 +1318,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) + const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) const worktreeDragSessionRef = useRef(null) const worktreePointerDragRef = useRef(null) const worktreePointerAutoscrollFrameIdRef = useRef(null) @@ -1494,6 +1499,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp groupKey: string rects: readonly WorktreeSidebarDragRect[] draggedIds: readonly string[] + draggingWorktreeId?: string | null }): WorktreeSidebarDropPreview | null => { const container = scrollRef.current if (!container) { @@ -1510,7 +1516,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp scrollTop: container.scrollTop, rects: args.rects, groupIds: group.worktreeIds, - draggedIds: args.draggedIds + draggedIds: args.draggedIds, + draggingWorktreeId: args.draggingWorktreeId }) }, [worktreeDragUnitGroups] @@ -1525,7 +1532,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pointerY, groupKey: session.sourceGroupKey, rects: session.rects, - draggedIds: session.reorderUnitDraggedIds + draggedIds: session.reorderUnitDraggedIds, + draggingWorktreeId: session.draggingWorktreeId }) }, [computeWorktreeDropForGroup] @@ -1545,7 +1553,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pointerY: args.pointerY, groupKey, rects: getWorktreeSidebarDragRectsForGroup(container, groupKey), - draggedIds: args.draggedIds + draggedIds: args.draggedIds, + draggingWorktreeId: worktreeDragSessionRef.current?.draggingWorktreeId ?? null }) }, [computeWorktreeDropForGroup] @@ -2587,6 +2596,37 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [assignWorktreeParent, getEligibleLineageDropTarget] ) + const clearReorderedWorktreeParents = useCallback( + (args: { draggedIds: readonly string[]; sourceGroupKey: string }) => { + const sourceGroup = worktreeDragGroups.find((group) => group.key === args.sourceGroupKey) + if (!sourceGroup) { + return + } + const ids = getReorderedWorktreeIdsToUnnest({ + draggedIds: args.draggedIds, + sourceGroupIds: sourceGroup.worktreeIds, + lineageById: worktreeLineageById + }) + if (ids.length === 0) { + return + } + // Why: dropping a nested card onto a reorder line is the escape hatch + // from accidental nesting, so clear only the directly dragged children. + void Promise.all(ids.map((id) => updateWorktreeLineage(id, { noParent: true }))).catch( + (err) => { + console.error('Failed to unnest workspace:', err) + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.failedUnnestWorkspace', + 'Failed to unnest workspace' + ) + ) + } + ) + }, + [updateWorktreeLineage, worktreeDragGroups, worktreeLineageById] + ) + const flushWorktreePointerDrag = useCallback(() => { const drag = worktreePointerDragRef.current if (!drag) { @@ -3101,6 +3141,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp dropIndex: drop.dropIndex }) }) + clearReorderedWorktreeParents({ + draggedIds: drag.draggedIds, + sourceGroupKey: drag.sourceGroupKey + }) } else if (scrollRef.current) { const currentTarget = preferredStatusTarget const currentPreview = currentTarget.status @@ -3157,6 +3201,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }, [ beginWorktreePointerDrag, clearWorktreeDrag, + clearReorderedWorktreeParents, commitWorktreeLineageParentDrop, computeWorktreeDrop, computeWorktreeStatusDrop, @@ -3504,10 +3549,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp dropIndex: drop.dropIndex }) }) + clearReorderedWorktreeParents({ + draggedIds: session.draggedIds, + sourceGroupKey: session.sourceGroupKey + }) clearWorktreeDrag() }, [ clearWorktreeDrag, + clearReorderedWorktreeParents, commitWorktreeLineageParentDrop, computeWorktreeDrop, computeWorktreeStatusDrop, @@ -3757,6 +3807,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp dropIndex: drop.dropIndex }) }) + clearReorderedWorktreeParents({ + draggedIds: session.draggedIds, + sourceGroupKey: session.sourceGroupKey + }) clearWorktreeDrag() } @@ -3764,6 +3818,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return () => document.removeEventListener('drop', handleDocumentDrop, true) }, [ clearWorktreeDrag, + clearReorderedWorktreeParents, commitWorktreeLineageParentDrop, computeWorktreeDrop, computeWorktreeStatusDrop, @@ -4615,11 +4670,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onClickCapture={handleWorktreeRowClickCapture} onDoubleClick={nested ? stopNestedWorktreeCardBubble : undefined} onDragStart={nested ? stopNestedWorktreeCardBubble : undefined} - onPointerDown={(event) => - nested - ? undefined - : handleWorktreeRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey) - } + onPointerDown={(event) => { + if (nested) { + event.stopPropagation() + } + handleWorktreeRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey) + }} style={{ paddingLeft: surfaceInset > 0 ? `${surfaceInset}px` : undefined }} diff --git a/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts b/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts index 13d5281d1ad..678cee74421 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts @@ -38,18 +38,55 @@ function getFallbackGap(rects: readonly WorktreeDragPreviewRect[]): number { return gaps[Math.floor(gaps.length / 2)]! } +function getPreviewLayoutDraggedIds( + groupIds: readonly string[], + draggedIds: readonly string[], + draggingWorktreeId?: string | null +): readonly string[] { + if (draggedIds.length <= 1) { + return draggedIds + } + const draggedSet = new Set(draggedIds) + if ( + draggingWorktreeId && + draggedSet.has(draggingWorktreeId) && + groupIds.includes(draggingWorktreeId) + ) { + return [draggingWorktreeId] + } + const firstVisibleDraggedId = groupIds.find((id) => draggedSet.has(id)) + return firstVisibleDraggedId ? [firstVisibleDraggedId] : draggedIds.slice(0, 1) +} + export function buildWorktreeDragPreviewOffsets(args: { groupIds: readonly string[] draggedIds: readonly string[] + draggingWorktreeId?: string | null dropIndex: number rects: readonly WorktreeDragPreviewRect[] }): Map { - const nextIds = moveWorktreeIdsWithinGroup(args.groupIds, args.draggedIds, args.dropIndex) + const committedNextIds = moveWorktreeIdsWithinGroup( + args.groupIds, + args.draggedIds, + args.dropIndex + ) + if (arraysEqual(committedNextIds, args.groupIds)) { + return new Map() + } + + // Why: dragging a large multi-select batch should advertise the insertion + // point without opening a giant hole that makes the sidebar jump around. + const layoutDraggedIds = getPreviewLayoutDraggedIds( + args.groupIds, + args.draggedIds, + args.draggingWorktreeId + ) + const nextIds = moveWorktreeIdsWithinGroup(args.groupIds, layoutDraggedIds, args.dropIndex) if (arraysEqual(nextIds, args.groupIds)) { return new Map() } - const draggedSet = new Set(args.draggedIds) + const draggedSet = new Set(layoutDraggedIds) const newIndexById = new Map() nextIds.forEach((id, index) => newIndexById.set(id, index)) diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts new file mode 100644 index 00000000000..0f70912df1c --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + getReorderedWorktreeIdsToUnnest, + getWorktreeLineageDropTargetId, + isWorktreeLineageDropZoneHit +} from './worktree-lineage-drag-drop' + +describe('isWorktreeLineageDropZoneHit', () => { + it('keeps the top and bottom of a card available for reorder drops', () => { + const rect = { top: 100, bottom: 200 } as DOMRect + + expect(isWorktreeLineageDropZoneHit({ pointerY: 120, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 150, rect })).toBe(true) + expect(isWorktreeLineageDropZoneHit({ pointerY: 180, rect })).toBe(false) + }) + + it('caps the parent-drop band on tall cards', () => { + const rect = { top: 0, bottom: 180 } as DOMRect + + expect(isWorktreeLineageDropZoneHit({ pointerY: 67, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 90, rect })).toBe(true) + expect(isWorktreeLineageDropZoneHit({ pointerY: 113, rect })).toBe(false) + }) +}) + +describe('getWorktreeLineageDropTargetId', () => { + it('returns the row id only when the pointer is in the card content middle band', () => { + const { container, target } = makeTarget({ worktreeId: 'parent', top: 100, bottom: 200 }) + + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 120 })).toBeNull() + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent') + }) + + it('ignores content targets outside the sidebar container', () => { + const { container, target } = makeTarget({ + worktreeId: 'parent', + top: 100, + bottom: 200, + contained: false + }) + + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBeNull() + }) +}) + +describe('getReorderedWorktreeIdsToUnnest', () => { + it('clears parents only for directly dragged nested cards', () => { + expect( + getReorderedWorktreeIdsToUnnest({ + draggedIds: ['child', 'child', 'root', 'grandchild'], + sourceGroupIds: ['child', 'root', 'grandchild'], + lineageById: { + child: true, + grandchild: true + } + }) + ).toEqual(['child', 'grandchild']) + }) + + it('does not clear selected nested cards outside the reordered source group', () => { + expect( + getReorderedWorktreeIdsToUnnest({ + draggedIds: ['source-child', 'other-child'], + sourceGroupIds: ['source-child'], + lineageById: { + 'source-child': true, + 'other-child': true + } + }) + ).toEqual(['source-child']) + }) +}) + +function makeTarget(args: { + worktreeId: string + top: number + bottom: number + contained?: boolean +}): { + container: HTMLElement + target: Element +} { + const row = { + getAttribute: (name: string) => (name === 'data-worktree-drag-id' ? args.worktreeId : null) + } as HTMLElement + const content = { + getBoundingClientRect: () => ({ top: args.top, bottom: args.bottom }), + closest: (selector: string) => (selector === '[data-worktree-drag-id]' ? row : null) + } as HTMLElement + const target = { + closest: (selector: string) => + selector === '[data-worktree-card-hover-trigger]' ? content : null + } as Element + const contained = args.contained ?? true + const container = { + contains: (element: Element) => contained && (element === content || element === row) + } as HTMLElement + return { container, target } +} diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts new file mode 100644 index 00000000000..dc011d33a77 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts @@ -0,0 +1,68 @@ +const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-hover-trigger]' +const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]' + +const LINEAGE_DROP_ZONE_RATIO = 0.4 +const LINEAGE_DROP_ZONE_MAX_HEIGHT_PX = 44 + +type VerticalRect = Pick + +export function isWorktreeLineageDropZoneHit(args: { + pointerY: number + rect: VerticalRect +}): boolean { + const height = Math.max(0, args.rect.bottom - args.rect.top) + if (height <= 0) { + return false + } + + const zoneHeight = Math.min(height * LINEAGE_DROP_ZONE_RATIO, LINEAGE_DROP_ZONE_MAX_HEIGHT_PX) + const zoneTop = args.rect.top + (height - zoneHeight) / 2 + const zoneBottom = args.rect.bottom - (height - zoneHeight) / 2 + return args.pointerY >= zoneTop && args.pointerY <= zoneBottom +} + +export function getWorktreeLineageDropTargetId(args: { + container: HTMLElement + target: Element + pointerY: number +}): string | null { + const contentTarget = args.target.closest(WORKTREE_CARD_CONTENT_TARGET_SELECTOR) + if (!contentTarget || !args.container.contains(contentTarget)) { + return null + } + + // Why: nesting should be deliberate; the top/bottom of a card stays available + // for reorder drops instead of treating the whole card as a parent target. + if ( + !isWorktreeLineageDropZoneHit({ + pointerY: args.pointerY, + rect: contentTarget.getBoundingClientRect() + }) + ) { + return null + } + + const rowTarget = contentTarget.closest(WORKTREE_DRAG_ROW_SELECTOR) + if (!rowTarget || !args.container.contains(rowTarget)) { + return null + } + return rowTarget.getAttribute('data-worktree-drag-id') +} + +export function getReorderedWorktreeIdsToUnnest(args: { + draggedIds: readonly string[] + sourceGroupIds: readonly string[] + lineageById: Readonly> +}): string[] { + const ids: string[] = [] + const seen = new Set() + const sourceGroupIdSet = new Set(args.sourceGroupIds) + for (const id of args.draggedIds) { + if (seen.has(id) || !sourceGroupIdSet.has(id) || !args.lineageById[id]) { + continue + } + seen.add(id) + ids.push(id) + } + return ids +} diff --git a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts index 7753afd8cd3..3847c2168b6 100644 --- a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts +++ b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts @@ -146,6 +146,64 @@ describe('buildWorktreeDragPreviewOffsets', () => { expect(Array.from(offsets)).toEqual([['parent', 108]]) }) + + it('reserves one card-height slot while previewing a multi-select batch', () => { + const offsets = buildWorktreeDragPreviewOffsets({ + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'b', + dropIndex: 5, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ] + }) + + expect(Array.from(offsets)).toEqual([ + ['c', -56], + ['d', -56], + ['e', -56] + ]) + }) + + it('uses the grabbed selected card as the one preview placeholder', () => { + const offsets = buildWorktreeDragPreviewOffsets({ + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'd', + dropIndex: 5, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ] + }) + + expect(Array.from(offsets)).toEqual([['e', -56]]) + }) + + it('returns no preview offsets for a no-op multi-select hover', () => { + const offsets = buildWorktreeDragPreviewOffsets({ + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'b', + dropIndex: 4, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ] + }) + + expect(offsets.size).toBe(0) + }) }) describe('buildManualOrderUpdatesForVisibleGroups', () => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts index 2e3c5582abc..a197759a245 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts @@ -183,7 +183,7 @@ describe('refreshWorktreeSidebarDragSession', () => { ).toBeNull() expect( refreshWorktreeSidebarDragSession({ - session: SESSION, + session: { ...SESSION, reorderUnitDraggedIds: ['missing'] }, groups: [{ key: 'repo:one', worktreeIds: ['a', 'b'] }], unitGroups: [{ key: 'repo:one', worktreeIds: ['a'], units: [] }], rects: [] @@ -201,6 +201,39 @@ describe('refreshWorktreeSidebarDragSession', () => { }) ).toEqual({ ...SESSION, rects: [] }) }) + + it('keeps child-card reorder drags even when the child is not a top-level unit', () => { + const childSession: WorktreeSidebarDragSession = { + ...SESSION, + draggingWorktreeId: 'child', + draggedIds: ['child'], + reorderDraggedIds: ['child'], + reorderUnitDraggedIds: ['child'] + } + const rects: WorktreeSidebarDragRect[] = [ + { worktreeId: 'parent', groupIndex: 0, top: 0, bottom: 80 }, + { worktreeId: 'child', groupIndex: 1, top: 88, bottom: 128 }, + { worktreeId: 'sibling', groupIndex: 2, top: 136, bottom: 176 } + ] + + expect( + refreshWorktreeSidebarDragSession({ + session: childSession, + groups: [{ key: 'repo:one', worktreeIds: ['parent', 'child', 'sibling'] }], + unitGroups: [ + { + key: 'repo:one', + worktreeIds: ['parent', 'sibling'], + units: [ + { worktreeId: 'parent', worktreeIds: ['parent', 'child'] }, + { worktreeId: 'sibling', worktreeIds: ['sibling'] } + ] + } + ], + rects + }) + ).toEqual({ ...childSession, rects }) + }) }) function makeContainer(elements: readonly ReturnType[]): HTMLElement { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts index 4ccbf340549..27e2a3d7d2d 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts @@ -180,8 +180,13 @@ export function refreshWorktreeSidebarDragSession(args: { return null } + const sourceGroupIds = new Set(sourceGroup.worktreeIds) const sourceUnitIds = new Set(sourceUnitGroup.worktreeIds) - if (args.session.reorderUnitDraggedIds.some((worktreeId) => !sourceUnitIds.has(worktreeId))) { + if ( + args.session.reorderUnitDraggedIds.some( + (worktreeId) => !sourceUnitIds.has(worktreeId) && !sourceGroupIds.has(worktreeId) + ) + ) { return null } diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts index 69f4c693de0..9fb1e630276 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts @@ -60,6 +60,58 @@ describe('computeWorktreeSidebarDropPreview', () => { }) expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['sibling', -288]]) }) + + it('uses one card-height placeholder for multi-select reorder previews', () => { + const preview = computeWorktreeSidebarDropPreview({ + pointerY: 280, + containerTop: 0, + scrollTop: 0, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ], + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'b' + }) + + expect(preview).toMatchObject({ + dropIndex: 5, + dropIndicatorY: 277 + }) + expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([ + ['c', -56], + ['d', -56], + ['e', -56] + ]) + }) + + it('uses the grabbed selected card as the multi-select preview placeholder', () => { + const preview = computeWorktreeSidebarDropPreview({ + pointerY: 280, + containerTop: 0, + scrollTop: 0, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ], + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'd' + }) + + expect(preview).toMatchObject({ + dropIndex: 5, + dropIndicatorY: 277 + }) + expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['e', -56]]) + }) }) describe('resolveWorktreeSidebarStatusDropCommitTarget', () => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts index b150188bdb9..d3702968e59 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts @@ -99,6 +99,7 @@ export function computeWorktreeSidebarDropPreview(args: { rects: readonly WorktreeSidebarDragRect[] groupIds: readonly string[] draggedIds: readonly string[] + draggingWorktreeId?: string | null }): WorktreeSidebarDropPreview | null { const rects = getWorktreeSidebarDragUnitRects({ rects: args.rects, @@ -139,6 +140,7 @@ export function computeWorktreeSidebarDropPreview(args: { const previewOffsetsByWorktreeId = buildWorktreeDragPreviewOffsets({ groupIds: args.groupIds, draggedIds: args.draggedIds, + draggingWorktreeId: args.draggingWorktreeId, dropIndex, rects }) From aa1744e30e30e282b52cc3ae71746b783a410a8b Mon Sep 17 00:00:00 2001 From: lvfen <284437334@qq.com> Date: Thu, 2 Jul 2026 17:41:07 +0800 Subject: [PATCH 04/70] Fix cursor agent ime anchor (#7061) * fix: correct IME cursor after terminal reattach * fix: stabilize Cursor Agent IME anchor * Fix Cursor Agent reattach IME detection * Harden live-agent reattach detection and always restore cursor visibility - POST_REPLAY_LIVE_AGENT_REATTACH_RESET now includes ?25h so a false-positive agent signal (or dead TUI leftovers) can never leave a shell with a permanently hidden cursor; only focus reporting (?1004h) is preserved. - Replace the broad getAgentLabel token match (fires on titles like 'ssh devin@host') with an exact cursor-agent native-title check. - Anchor the replay-payload screen signal on the LAST 'Cursor Agent' header so scrollback from an earlier finished run does not classify the current screen. - Make the payload-signal flag replay-shape-aware: incremental (clearBeforeReplay:false) frames can only add evidence; full-screen replays remain authoritative. Co-authored-by: Orca * Fix anchor scan direction and drop non-decaying launch metadata from liveness - Scan the visible screen bottom-up in resolveCursorAgentImeAnchor so a transcript line containing an arrow (e.g. a rename diff) cannot hijack the IME anchor away from the input box. - Remove getAuthoritativePaneAgent() from hasLiveAgentReattachSignal: tab.launchAgent never decays after the agent exits, so it would preserve ?1004h and inject focus-in into the shell left behind by a dead agent. - Clear a stale replay-payload agent signal on an authoritative empty clearing frame. Co-authored-by: Orca * Veto scrollback-only Cursor Agent signal against the parsed viewport The replay-byte screen signal also matches a dead run's screen sitting in scrollback (daemon snapshots serialize full scrollback; the relay buffer is never cleared). After xterm parses the replay, confirm a payload-only signal against the visible screen via resolveCursorAgentImeAnchor; when the viewport shows a plain shell, drop focus reporting (?1004l) and skip the focus-in so a bare shell never inherits stale focus modes or receives stray focus bytes. Co-authored-by: Orca * Make the reattach viewport veto shape-based and latest-frame-only - parsedViewportShowsCursorAgentScreen now checks the screen shape (header + input row) via viewportShowsCursorAgentScreen instead of requiring the parked-cursor precondition, so a live agent whose cursor sits at the caret is not downgraded. - Add a replay-signal generation counter so a stale post-parse callback stands down when a newer replay frame has updated the signal. Co-authored-by: Orca * Require the parked cursor in the reattach viewport confirmation A dead cursor-agent screen can still be painted in the viewport with the shell prompt below it, which passes a shape-only check and would preserve ?1004h and inject a focus-in into the shell. Confirm a payload-only signal with resolveCursorAgentImeAnchor (shape + parked cursor): a live agent that needs the focus-in is by definition parked, and an unparked live agent only falls back to the pre-fix reattach behavior. Co-authored-by: Orca * Complete window stub in pty-connection tests for command-finished event Main's onCommandFinished now dispatches a window CustomEvent synchronously, but the node-env connectPanePty window stub omits dispatchEvent. These tests only passed when another test file in the same vitest worker happened to leave a window with dispatchEvent; this branch's added test files shift sharding and expose the gap. Add dispatchEvent/addEventListener/removeEventListener to the stub so the command-finished path is covered deterministically. Co-authored-by: Orca --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../terminal-pane/layout-serialization.ts | 27 +- .../terminal-pane/pty-connection.test.ts | 368 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 219 ++++++++++- .../terminal-replay-cursor-state.test.ts | 111 ++++++ .../src/lib/pane-manager/pane-lifecycle.ts | 44 ++- .../pane-manager/terminal-ime-anchor.test.ts | 156 ++++++++ .../lib/pane-manager/terminal-ime-anchor.ts | 122 ++++++ 7 files changed, 1015 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index 423a08b8697..b2ab8d1e2ab 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -58,27 +58,32 @@ export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KIT export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l` // Why: daemon snapshot restore reattaches to a live session, so we avoid the -// full POST_REPLAY_MODE_RESET bundle there — a still-running TUI may still -// rely on mouse or bracketed-paste modes. Four exceptions are safe to reset: +// full POST_REPLAY_MODE_RESET bundle there. The default still clears the two +// stale mode bits most harmful to a plain shell after a TUI exits badly: // // 0 q — DECSCUSR cursor style/blink reset: raw replay can contain a stale // steady cursor override, while SerializeAddon does not preserve an // authoritative current cursor style. Reset to the user's configured // xterm cursor; the post-reattach SIGWINCH lets live TUIs repaint if // they need a different cursor. -// 25 — DECTCEM cursor visibility: SerializeAddon bakes `?25l` into the -// snapshot when the cursor was hidden at capture time. Without `?25h` -// here the cursor stays invisible after reattach. If a TUI is still -// running and wants the cursor hidden, the SIGWINCH sent immediately -// after restore triggers a repaint that re-hides it — a brief flash -// that is far less harmful than a permanently invisible cursor. -// 1004 — focus event reporting: preserving `?1004h` makes restored shells -// ring BEL on pane focus/blur (shells like zsh treat `\e[I`/`\e[O` -// as unbound key input). +// 25 — DECTCEM cursor visibility: snapshots can preserve hidden cursor +// state from a no-longer-running TUI; reset by default so shells do +// not inherit a permanently invisible cursor. +// 1004 — focus event reporting: snapshots can preserve focus reporting from +// a no-longer-running TUI; reset by default so shells do not receive +// stray focus-in/focus-out bytes. // <99u/=0u — Kitty keyboard mode is renderer-side xterm state; stale copies // can make the next Ctrl+C encode as CSI-u after reattach. export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h\x1b[?1004l` +// Why: a live agent TUI legitimately owns focus reporting; resetting `?1004h` +// would suppress the post-reattach focus-in the agent needs to move its real +// cursor back to the input caret (the IME anchor). Cursor visibility is still +// reset: agent detection can false-positive on a dead TUI's leftovers, and a +// permanently invisible shell cursor is far worse than the brief flash a live +// agent re-hides on its post-reattach SIGWINCH repaint. +export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h` + // Cross-platform monospace fallback chain ensures the terminal always has a // usable font regardless of OS. macOS-only fonts like SF Mono and Menlo are // harmless on other platforms (the browser skips them), while Cascadia Mono / diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 01ad840dcbe..cd806774737 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -2,6 +2,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, RESET_KITTY_KEYBOARD_PROTOCOL, @@ -330,7 +331,8 @@ function createPane(paneId: number) { active: activeBuffer }, modes: { - bracketedPasteMode: false + bracketedPasteMode: false, + sendFocusMode: false }, options: { ignoreBracketedPasteMode: false, @@ -449,6 +451,52 @@ function createDeps(overrides: Record = {}) { } } +function setReattachPaneTitle(title: string): void { + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', title }] + }, + runtimePaneTitlesByTabId: { + 'tab-1': { 1: title } + } + } as StoreState +} + +async function withMockedDocumentActiveElement( + activeElement: unknown, + run: () => Promise +): Promise { + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document') + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { activeElement } + }) + try { + return await run() + } finally { + if (originalDocument) { + Object.defineProperty(globalThis, 'document', originalDocument) + } else { + Reflect.deleteProperty(globalThis, 'document') + } + } +} + +function configureTerminalFocusMode( + pane: ReturnType, + textarea: HTMLTextAreaElement +): void { + Object.assign(pane.terminal, { textarea }) + Object.assign(pane.terminal.modes, { sendFocusMode: true }) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => { + callback?.() + }) +} + +const ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN = + '\x1b[4;3HCursor Agent\x1b[5;3Hv2026.06.29\x1b[9;3H→ Plan, search, build anything' + // Why: setting activeRuntimeEnvironmentId in mockStoreState exercises the // remote-runtime path where the renderer still owns OSC 9999 status. function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { @@ -709,7 +757,10 @@ describe('connectPanePty', () => { agentStatus: { inferInterrupt: vi.fn().mockResolvedValue(false) } - } + }, + dispatchEvent: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn() } globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { callback(0) @@ -4023,7 +4074,7 @@ describe('connectPanePty', () => { expect(transport.connect).toHaveBeenCalledTimes(1) }) - it('resets reattach cursor/focus state after daemon snapshot replay without applying the full mode reset', async () => { + it('resets reattach renderer state after daemon snapshot replay without applying the full mode reset', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { @@ -4068,6 +4119,241 @@ describe('connectPanePty', () => { ) }) + it('preserves live modes and injects focus-in after focused agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + // Why: public xterm modes plus an agent title are the stable signal for a + // live focus-driven TUI; avoid private `_core` field probes. + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('does not inject focus-in after reattach when the terminal does not own DOM focus', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + // Why: a different element owns focus, so the reattach must not send a stray + // focus-in to a background pane. + await withMockedDocumentActiveElement({}, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('resets stale focus and cursor modes for a focused non-agent shell reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('zsh') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('does not treat persisted tab launchAgent metadata as a live agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + // Why: launchAgent is launch ownership metadata that never decays after + // the agent exits; it must not preserve stale modes for the shell left + // behind after an unclean agent death. + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', title: 'zsh', launchAgent: 'claude' }] + }, + runtimePaneTitlesByTabId: { + 'tab-1': { 1: 'zsh' } + } + } as StoreState + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('does not treat an agent-name token in a shell title as a live agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + // Why: broad token matching would classify this ordinary ssh title as an + // agent and preserve stale modes / inject focus-in into a bare shell. + setReattachPaneTitle('ssh devin@host') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('does not treat ordinary shell scrollback mentioning Cursor Agent as a live agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: + '\x1b[?1004h\x1b[?25l$ grep -R "Cursor Agent" docs\r\nCursor Agent IME notes\r\n' + } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('zsh') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + it('resets an already-idle agent cursor again after reattach SIGWINCH repaint', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('tab-pty') @@ -8260,6 +8546,82 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('preserves live agent modes when queued replay data carries the Cursor Agent screen', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('renamed shell') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + const manager = createManager(1) + const deps = createDeps() + const disposable = await withMockedDocumentActiveElement(textarea, async () => { + const connection = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.(ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN) + await flushAsyncTicks(12) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + return connection + }) + disposable.dispose() + }) + + it('downgrades a scrollback-only Cursor Agent signal when the parsed viewport shows a shell', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('renamed shell') + + const pane = createPane(1) + // Why: an inspectable buffer whose visible rows carry no Cursor Agent + // screen models a shell foreground after a dead run left its screen in + // scrollback. + Object.assign(pane.terminal.buffer.active, { + cursorX: 2, + getLine: () => undefined + }) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + const manager = createManager(1) + const deps = createDeps() + const disposable = await withMockedDocumentActiveElement(textarea, async () => { + const connection = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.(ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN) + await flushAsyncTicks(12) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?1004l', expect.any(Function)) + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + return connection + }) + disposable.dispose() + }) + it('does not clear restored scrollback when eager metadata replay opts out', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f9b025da2c5..85c50f6fe8a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1,7 +1,8 @@ /* oxlint-disable max-lines */ import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types' -import type { IDisposable } from '@xterm/xterm' +import type { IBuffer, IDisposable } from '@xterm/xterm' +import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor' import { detectAgentStatusFromTitle, isGeminiTerminalTitle, @@ -49,6 +50,7 @@ import { type PanePtyResizeHoldFlushDetail } from '@/lib/pane-manager/pane-pty-resize-hold' import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, @@ -187,6 +189,8 @@ const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - 1 const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' +const TERMINAL_FOCUS_IN_SEQUENCE = '\x1b[I' +const FOCUS_REPORTING_DISABLE_SEQUENCE = '\x1b[?1004l' const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 const FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS = 2048 const FOREGROUND_INTERACTIVE_REDRAW_CHARS = 128 * 1024 @@ -212,6 +216,103 @@ type E2eTerminalPtyDataInjectionApi = { keys: () => string[] } +type TerminalWithFocusMode = { + textarea?: HTMLTextAreaElement | null + modes?: { + sendFocusMode?: boolean + } +} + +type TerminalWithInspectableBuffer = { + cols: number + rows: number + buffer?: { + active?: IBuffer + } +} + +// Why: replay bytes can carry a dead run's screen in scrollback — or still +// painted in the viewport with a shell prompt below it — so once xterm has +// parsed the replay the confirmation needs both the cursor-agent screen shape +// AND the parked cursor. A dead screen leaves the shell cursor after its +// prompt; a live agent that needs the focus-in is by definition parked, and a +// live agent that is not parked only loses focus reporting the way the +// pre-fix reattach always did. Returns null when the buffer is not +// inspectable (e.g. test doubles). +function parsedViewportShowsParkedCursorAgentScreen( + terminal: TerminalWithInspectableBuffer +): boolean | null { + const buffer = terminal.buffer?.active + if ( + !buffer || + typeof buffer.getLine !== 'function' || + typeof buffer.cursorX !== 'number' || + typeof buffer.cursorY !== 'number' + ) { + return null + } + return ( + resolveCursorAgentImeAnchor({ + buffer, + rows: terminal.rows, + cols: terminal.cols, + cursorX: buffer.cursorX, + cursorY: buffer.cursorY + }) !== null + ) +} + +function terminalHasFocusReportingEnabled(terminal: TerminalWithFocusMode): boolean { + return terminal.modes?.sendFocusMode === true +} + +function terminalOwnsDomFocus(terminal: TerminalWithFocusMode): boolean { + if (typeof document === 'undefined' || !terminal.textarea) { + return false + } + return document.activeElement === terminal.textarea +} + +function stripAnsiCsiSequences(data: string): string { + let normalized = '' + let index = 0 + while (index < data.length) { + if (data.charCodeAt(index) === 0x1b && data[index + 1] === '[') { + index += 2 + while (index < data.length) { + const code = data.charCodeAt(index) + index += 1 + if (code >= 0x40 && code <= 0x7e) { + break + } + } + continue + } + normalized += data[index] + index += 1 + } + return normalized +} + +const CURSOR_AGENT_REATTACH_HEADER = 'Cursor Agent' +const CURSOR_AGENT_REATTACH_INPUT_MARKER = '→' +const CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS = 5000 + +function hasCursorAgentReattachPayloadScreenSignal(data: string): boolean { + const normalized = stripAnsiCsiSequences(data) + // Why: anchor on the LAST header occurrence — replay buffers keep scrollback, + // and an earlier finished run must not classify the current screen. + const headerIndex = normalized.lastIndexOf(CURSOR_AGENT_REATTACH_HEADER) + if (headerIndex === -1) { + return false + } + const screenTail = normalized.slice( + headerIndex + CURSOR_AGENT_REATTACH_HEADER.length, + headerIndex + CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS + ) + return screenTail.includes(`${CURSOR_AGENT_REATTACH_INPUT_MARKER} `) +} + type E2eTerminalPtyDataInjectionWindow = Window & { __terminalPtyDataInjection?: E2eTerminalPtyDataInjectionApi __terminalHiddenSnapshotOverride?: E2eTerminalHiddenSnapshotOverrideApi @@ -1228,6 +1329,52 @@ export function connectPanePty( )?.title return runtimeTitle ?? tabTitle ?? null } + let reattachReplayPayloadHasCursorAgentSignal = false + // Why: post-parse veto callbacks must only judge the latest replay frame; a + // newer frame bumps the generation so a stale callback stands down. + let reattachReplayPayloadSignalGeneration = 0 + const rememberReattachPayloadAgentSignal = ( + data: string, + opts: { fullScreenReplay: boolean } + ): void => { + reattachReplayPayloadSignalGeneration += 1 + // Why: ordinary scrollback can mention agent names. Treat replay bytes as + // a live Cursor Agent signal only when they look like its restored screen. + const signal = hasCursorAgentReattachPayloadScreenSignal(data) + // Why: incremental (non-clearing) replay frames repaint only part of the + // screen, so their bytes can only add evidence — a full-screen replay is + // the authoritative repaint that may clear the flag. + reattachReplayPayloadHasCursorAgentSignal = opts.fullScreenReplay + ? signal + : reattachReplayPayloadHasCursorAgentSignal || signal + } + const isCursorAgentNativeTitle = (title: string): boolean => { + return title.trim().toLowerCase() === CURSOR_AGENT_REATTACH_HEADER.toLowerCase() + } + const hasLiveAgentReattachStatusOrTitleSignal = (): boolean => { + // Why: launch ownership (tab.launchAgent) never decays after the agent + // exits, so it must not count as liveness here — only live status, live + // titles, and the replayed screen shape do. + if (useAppStore.getState().agentStatusByPaneKey[cacheKey]) { + return true + } + const title = getCurrentTerminalTitle() ?? '' + // Why: broad token matching (getAgentLabel) fires on titles like + // "ssh devin@host"; that surface is too loose to gate mode preservation + // and PTY byte injection, so only exact/status titles count here. + return detectAgentStatusFromTitle(title) !== null || isCursorAgentNativeTitle(title) + } + const hasLiveAgentReattachSignal = (): boolean => { + return hasLiveAgentReattachStatusOrTitleSignal() || reattachReplayPayloadHasCursorAgentSignal + } + const shouldPreserveAgentReattachModes = (): boolean => { + // Why: ordinary shells can inherit stale ?25l/?1004h from replay bytes. + // Preserve those modes only when reattach still looks agent-owned. + return hasLiveAgentReattachSignal() + } + const shouldSendFocusedAgentReattachFocusIn = (): boolean => { + return terminalOwnsDomFocus(pane.terminal) && shouldPreserveAgentReattachModes() + } const scheduleReattachIdleAgentCursorReset = (): void => { const status = detectAgentStatusFromTitle(getCurrentTerminalTitle() ?? '') if (status !== 'idle' && status !== 'permission') { @@ -3359,6 +3506,52 @@ export function connectPanePty( return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data) } + const reattachReplayResetSequence = (): string => { + return shouldPreserveAgentReattachModes() + ? POST_REPLAY_LIVE_AGENT_REATTACH_RESET + : POST_REPLAY_REATTACH_RESET + } + + const sendFocusedReattachFocusInAfterReplay = (): void => { + const scheduledGeneration = reattachReplayPayloadSignalGeneration + void waitForTerminalOutputParsed(pane.terminal).then(() => { + if (disposed) { + return + } + // Why: a newer replay frame owns the judgment; its own post-parse + // callback will re-evaluate against its own viewport. + if (scheduledGeneration !== reattachReplayPayloadSignalGeneration) { + return + } + // Why: the replay-byte signal also matches a dead run's screen — in + // scrollback or still painted above a fresh shell prompt. The parsed + // viewport is the ground truth; unless it shows a parked-cursor + // cursor-agent screen and no status/title corroborates, downgrade to + // the plain-shell behavior (drop focus reporting, skip focus-in). + if ( + !hasLiveAgentReattachStatusOrTitleSignal() && + reattachReplayPayloadHasCursorAgentSignal + ) { + if (parsedViewportShowsParkedCursorAgentScreen(pane.terminal) === false) { + reattachReplayPayloadHasCursorAgentSignal = false + writeReplayData(FOCUS_REPORTING_DISABLE_SEQUENCE) + return + } + } + // Why: a live TUI such as cursor-agent parks the real terminal cursor off + // its own input caret and moves it back only on a focus-in. Reattach + // reuses the same live PTY and the xterm textarea already holds DOM + // focus, so xterm never emits the focus-in the agent needs and the parked + // cursor anchors the IME/caret to the wrong cell. Gated on ?1004h so a + // bare shell never receives a stray \x1b[I. + const sendFocusMode = terminalHasFocusReportingEnabled(pane.terminal) + if (!shouldSendFocusedAgentReattachFocusIn() || !sendFocusMode) { + return + } + transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE) + }) + } + let replayWriteQueue = Promise.resolve() type PendingReplayData = { data: string @@ -3377,9 +3570,15 @@ export function connectPanePty( if (clearBeforeReplay) { await writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H') } + if (clearBeforeReplay || data.length > 0) { + // Why: an empty clearing frame is still an authoritative repaint and + // must clear a stale agent signal from an earlier payload. + rememberReattachPayloadAgentSignal(data, { fullScreenReplay: clearBeforeReplay }) + } await writeReplayDataAsync(data) if (clearBeforeReplay || data.length > 0) { - await writeReplayDataAsync(POST_REPLAY_REATTACH_RESET) + await writeReplayDataAsync(reattachReplayResetSequence()) + sendFocusedReattachFocusInAfterReplay() } if (disposed) { pendingReplayData = null @@ -4557,12 +4756,14 @@ export function connectPanePty( // the daemon and relay are by definition tracking the same session // and only the freshest source belongs on screen. if (connectResult?.snapshot) { + rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(connectResult.snapshot) // Snapshot reattach keeps a live session, so avoid the broader mode - // reset. We only drop stale cursor/focus state that should not leak - // from replay bytes into the restored renderer terminal. - writeReplayData(POST_REPLAY_REATTACH_RESET) + // reset. We only drop renderer-owned state that should not leak from + // replay bytes into the restored renderer terminal. + writeReplayData(reattachReplayResetSequence()) + sendFocusedReattachFocusInAfterReplay() if (connectResult.coldRestore) { // Snapshot superseded the cold-restore payload — ack it so the // daemon does not redeliver it on the next reattach. @@ -4571,13 +4772,15 @@ export function connectPanePty( } } } else if (connectResult?.replay) { + rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true }) // Relay replay holds the last 100 KB of raw output. The xterm may // already hold pre-disconnect content; clear first to avoid - // duplication. The reattach reset prevents stale cursor/focus mode - // bits in the replayed data from leaking into the restored terminal. + // duplication. The reattach reset clears renderer-owned state without + // tearing down the still-running TUI's live modes. writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(connectResult.replay) - writeReplayData(POST_REPLAY_REATTACH_RESET) + writeReplayData(reattachReplayResetSequence()) + sendFocusedReattachFocusInAfterReplay() if (connectResult.coldRestore) { if (!isRemoteRuntimePtyId(ptyId)) { window.api.pty.ackColdRestore(ptyId) diff --git a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts index 933de17d5fd..9e927552049 100644 --- a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { Terminal } from '@xterm/headless' import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, @@ -13,6 +14,7 @@ const OLD_REATTACH_RESET_WITHOUT_CURSOR_STYLE = '\x1b[?25h\x1b[?1004l' type DecPrivateCursorState = { cursorStyle?: string cursorBlink?: boolean + sendFocus?: boolean } type KittyKeyboardState = { @@ -28,10 +30,12 @@ type XtermWithCoreService = Terminal & { coreService?: { decPrivateModes?: DecPrivateCursorState kittyKeyboard?: KittyKeyboardState + isCursorHidden?: boolean } _coreService?: { decPrivateModes?: DecPrivateCursorState kittyKeyboard?: KittyKeyboardState + isCursorHidden?: boolean } } } @@ -42,6 +46,15 @@ function readDecPrivateCursorState(term: Terminal): DecPrivateCursorState { return cursorState ? { ...cursorState } : {} } +function readCursorHidden(term: Terminal): boolean | undefined { + const core = (term as XtermWithCoreService)._core + return core?.coreService?.isCursorHidden ?? core?._coreService?.isCursorHidden +} + +function readSendFocus(term: Terminal): boolean | undefined { + return readDecPrivateCursorState(term).sendFocus +} + function readKittyKeyboardState(term: Terminal): KittyKeyboardState | null { const core = (term as XtermWithCoreService)._core const keyboardState = core?.coreService?.kittyKeyboard ?? core?._coreService?.kittyKeyboard @@ -180,6 +193,104 @@ describe('terminal replay state reset', () => { } }) + it('resets hidden cursor visibility after ordinary reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + + try { + await writeTerminal(term, '\x1b[?25l') + expect(readCursorHidden(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_REATTACH_RESET) + + expect(readCursorHidden(term)).toBe(false) + } finally { + term.dispose() + } + }) + + it('resets focus reporting after ordinary reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + try { + await writeTerminal(term, '\x1b[?1004h') + expect(readSendFocus(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_REATTACH_RESET) + + expect(readSendFocus(term)).toBe(false) + } finally { + term.dispose() + } + }) + + it('resets hidden cursor visibility after live agent reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + + try { + await writeTerminal(term, '\x1b[?25l') + expect(readCursorHidden(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + + // Why: agent detection can false-positive on a dead TUI's leftovers, so + // even the live-agent reset must not leave a shell cursor permanently + // invisible; a live agent re-hides it on the post-reattach repaint. + expect(readCursorHidden(term)).toBe(false) + } finally { + term.dispose() + } + }) + + it('preserves live focus reporting after live agent reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + try { + await writeTerminal(term, '\x1b[?1004h') + expect(readSendFocus(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + + // Why: live TUIs such as cursor-agent can rely on focus events to repaint + // their own hidden-cursor input caret after renderer reattach. + expect(readSendFocus(term)).toBe(true) + } finally { + term.dispose() + } + }) + + it('shows the cursor after cold-restore replay reset', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + + try { + await writeTerminal(term, '\x1b[?25l') + expect(readCursorHidden(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_MODE_RESET) + + expect(readCursorHidden(term)).toBe(false) + } finally { + term.dispose() + } + }) + it('preserves active-buffer Kitty keyboard state after hidden-output snapshot replay', async () => { const term = new Terminal({ cols: 80, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index eb296a1d675..efdbea455ff 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -17,6 +17,7 @@ import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer' +import { resolveCursorAgentImeAnchor } from './terminal-ime-anchor' // --------------------------------------------------------------------------- // Pane creation, terminal open/close, addon management @@ -70,11 +71,10 @@ export function openTerminal(pane: ManagedPaneInternal): void { activateOrcaTerminalUnicodeProvider(terminal) // Why: the OS reads the focused textarea's screen rect at compositionstart to - // decide where to display the IME candidate window. xterm.js only repositions - // the textarea on compositionupdate (via updateCompositionElements), not on - // compositionstart, so the window can appear at a stale cursor position. We - // force-sync the textarea position in a capture-phase listener so the OS sees - // the correct location before it opens the candidate window. + // decide where to display the IME candidate window. xterm positions that + // textarea from its own cursor, which can be stale or intentionally hidden by + // TUIs. We force-sync after xterm's own composition handlers so the OS sees + // the corrected location before it opens the candidate window. // // Cell dimensions are derived from the public .xterm-screen element's bounds // (xterm sizes that element to cols*cellWidth × rows*cellHeight) rather than @@ -94,11 +94,34 @@ export function openTerminal(pane: ManagedPaneInternal): void { return } const buf = terminal.buffer.active - const x = Math.min(buf.cursorX, terminal.cols - 1) - textarea.style.top = `${buf.cursorY * cellHeight}px` - textarea.style.left = `${x * cellWidth}px` + // Why: Cursor Agent draws its prompt UI while leaving xterm's public cursor + // on a blank row, so the OS IME anchor needs the rendered prompt row instead. + const cursorAgentAnchor = resolveCursorAgentImeAnchor({ + buffer: buf, + rows: terminal.rows, + cols: terminal.cols, + cursorX: buf.cursorX, + cursorY: buf.cursorY + }) + const anchor = cursorAgentAnchor ?? { + row: buf.cursorY, + column: Math.min(buf.cursorX, terminal.cols - 1) + } + const applyAnchor = (): void => { + textarea.style.top = `${anchor.row * cellHeight}px` + textarea.style.left = `${anchor.column * cellWidth}px` + } + applyAnchor() + if (cursorAgentAnchor) { + window.setTimeout(() => { + if (textarea.isConnected) { + applyAnchor() + } + }, 0) + } } - terminal.element.addEventListener('compositionstart', handler, true) + terminal.element.addEventListener('compositionstart', handler) + terminal.element.addEventListener('compositionupdate', handler) // Store so disposePane() can remove it and avoid a memory leak. pane.compositionHandler = handler } @@ -200,7 +223,8 @@ export function disposePane( pane.terminalScrollIntentDisposable?.dispose() pane.terminalScrollIntentDisposable = null if (pane.compositionHandler) { - pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true) + pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler) + pane.terminal.element?.removeEventListener('compositionupdate', pane.compositionHandler) pane.compositionHandler = null } try { diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts new file mode 100644 index 00000000000..19de8afef2c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest' +import type { IBuffer, IBufferCell, IBufferLine } from '@xterm/xterm' +import { resolveCursorAgentImeAnchor } from './terminal-ime-anchor' + +type FakeCell = { + chars: string + width: number +} + +function makeCell(cell: FakeCell): IBufferCell { + return { + getWidth: () => cell.width, + getChars: () => cell.chars + } as IBufferCell +} + +function makeLine(text: string, cols = 80): IBufferLine { + const cells: FakeCell[] = [] + for (const char of Array.from(text)) { + const width = char === '你' ? 2 : 1 + cells.push({ chars: char, width }) + if (width === 2) { + cells.push({ chars: '', width: 0 }) + } + } + while (cells.length < cols) { + cells.push({ chars: '', width: 1 }) + } + + return { + isWrapped: false, + length: cells.length, + getCell: (column: number) => { + const cell = cells[column] + return cell ? makeCell(cell) : undefined + }, + translateToString: (trimRight = false, startColumn = 0, endColumn = cells.length) => { + let result = '' + for (let column = startColumn; column < endColumn; column++) { + const cell = cells[column] + if (!cell || cell.width === 0) { + continue + } + result += cell.chars || ' ' + } + return trimRight ? result.replace(/\s+$/, '') : result + } + } as IBufferLine +} + +function makeBuffer(lines: string[], cols = 80): IBuffer { + const bufferLines = lines.map((line) => makeLine(line, cols)) + return { + baseY: 0, + cursorX: 0, + cursorY: 0, + length: bufferLines.length, + getLine: (row: number) => bufferLines[row], + getNullCell: () => makeCell({ chars: '', width: 1 }) + } as IBuffer +} + +describe('resolveCursorAgentImeAnchor', () => { + it('anchors an empty Cursor Agent prompt at the visible prompt caret', () => { + const buffer = makeBuffer([ + '', + ' Cursor Agent', + ' v2026.06.29-2ad2186', + ' Tip: Use /config to customize Cursor settings and behavior.', + '', + '', + '', + '', + ' → Plan, search, build anything', + '', + '', + ' Composer 2.5', + ' ~/development/code/xinyue/app_android · develop/app6.5.1', + '' + ]) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 14, + cols: 80, + cursorX: 0, + cursorY: 13 + }) + ).toEqual({ row: 8, column: 4 }) + }) + + it('does not override normal terminal cursor positioning', () => { + const buffer = makeBuffer(['', ' → Plan, search, build anything', '', '']) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 4, + cols: 80, + cursorX: 0, + cursorY: 3 + }) + ).toBeNull() + }) + + it('does not override when xterm already exposes a non-stale cursor position', () => { + const buffer = makeBuffer(['', ' Cursor Agent', '', ' → Plan, search, build anything']) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 4, + cols: 80, + cursorX: 4, + cursorY: 3 + }) + ).toBeNull() + }) + + it('anchors the input row, not a transcript line containing an arrow', () => { + const buffer = makeBuffer([ + '', + ' Cursor Agent', + '', + ' Renamed a.ts → b.ts', + '', + ' → Plan, search, build anything', + '' + ]) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 7, + cols: 80, + cursorX: 0, + cursorY: 6 + }) + ).toEqual({ row: 5, column: 4 }) + }) + + it('uses cell width when anchoring after typed Cursor Agent input', () => { + const buffer = makeBuffer(['', ' Cursor Agent', '', ' → hi你', '']) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 5, + cols: 80, + cursorX: 0, + cursorY: 4 + }) + ).toEqual({ row: 3, column: 8 }) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts new file mode 100644 index 00000000000..bda4c751a88 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts @@ -0,0 +1,122 @@ +import type { IBuffer, IBufferCell, IBufferLine } from '@xterm/xterm' + +export type TerminalImeAnchor = { + row: number + column: number +} + +const CURSOR_AGENT_HEADER = 'Cursor Agent' +const CURSOR_AGENT_INPUT_MARKER = '→' +const CURSOR_AGENT_EMPTY_PROMPT = 'Plan, search, build anything' +const CURSOR_AGENT_HEADER_SCAN_ROWS = 6 + +export function resolveCursorAgentImeAnchor(args: { + buffer: IBuffer + rows: number + cols: number + cursorX: number + cursorY: number +}): TerminalImeAnchor | null { + const cursorLine = getVisibleLine(args.buffer, args.cursorY) + if (args.cursorX !== 0 || !isBlankLine(cursorLine)) { + return null + } + return findCursorAgentScreenInputAnchor(args) +} + +function findCursorAgentScreenInputAnchor(args: { + buffer: IBuffer + rows: number + cols: number +}): TerminalImeAnchor | null { + if (!hasCursorAgentHeader(args.buffer, args.rows)) { + return null + } + + // Why: the input box sits below the transcript, so scan bottom-up — a + // transcript line containing "→ " (e.g. a rename diff) must not win. + for (let row = args.rows - 1; row >= 0; row--) { + const line = getVisibleLine(args.buffer, row) + if (!line) { + continue + } + const column = resolveCursorAgentInputColumn(line, args.cols) + if (column !== null) { + return { row, column: Math.min(column, Math.max(args.cols - 1, 0)) } + } + } + + return null +} + +function getVisibleLine(buffer: IBuffer, row: number): IBufferLine | undefined { + return buffer.getLine(buffer.baseY + row) +} + +function hasCursorAgentHeader(buffer: IBuffer, rows: number): boolean { + const scanRows = Math.min(rows, CURSOR_AGENT_HEADER_SCAN_ROWS) + for (let row = 0; row < scanRows; row++) { + if (getVisibleLine(buffer, row)?.translateToString(true).trim() === CURSOR_AGENT_HEADER) { + return true + } + } + return false +} + +function resolveCursorAgentInputColumn(line: IBufferLine, cols: number): number | null { + const inputColumn = findCursorAgentInputStartColumn(line, cols) + if (inputColumn === null) { + return null + } + + const inputText = line.translateToString(true, inputColumn, cols) + if (!inputText.trim() || inputText.startsWith(CURSOR_AGENT_EMPTY_PROMPT)) { + return inputColumn + } + + return findLineContentEndColumn(line, inputColumn, cols) ?? inputColumn +} + +function findCursorAgentInputStartColumn(line: IBufferLine, cols: number): number | null { + const maxColumn = Math.min(line.length, cols) + for (let column = 0; column < maxColumn - 1; column++) { + const markerCell = line.getCell(column) + if (!isCellChar(markerCell, CURSOR_AGENT_INPUT_MARKER)) { + continue + } + + const nextColumn = column + Math.max(markerCell.getWidth(), 1) + if (nextColumn < maxColumn && isCellChar(line.getCell(nextColumn), ' ')) { + return nextColumn + 1 + } + } + return null +} + +function findLineContentEndColumn( + line: IBufferLine, + startColumn: number, + cols: number +): number | null { + const maxColumn = Math.min(line.length, cols) + for (let column = maxColumn - 1; column >= startColumn; column--) { + const cell = line.getCell(column) + if (!cell || cell.getWidth() === 0 || getCellChars(cell) === ' ') { + continue + } + return column + Math.max(cell.getWidth(), 1) + } + return null +} + +function isBlankLine(line: IBufferLine | undefined): boolean { + return !line || line.translateToString(true).trim() === '' +} + +function isCellChar(cell: IBufferCell | undefined, expected: string): cell is IBufferCell { + return !!cell && cell.getWidth() > 0 && getCellChars(cell) === expected +} + +function getCellChars(cell: IBufferCell): string { + return cell.getChars() || ' ' +} From e3f99a747276f2c6a2c6042ef8504c7142cd9e99 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:42:52 -0700 Subject: [PATCH 05/70] Bump mobile app.json to 0.0.20 (#7100) --- mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/app.json b/mobile/app.json index 9394a9a63dc..3a09a5531c8 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.18", + "version": "0.0.20", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", From 3ba36301509ef3ac8bd8698bad52bed882d8384c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:51:09 -0700 Subject: [PATCH 06/70] Move githubCache out of the durable state file into a quit-time sidecar (#7101) * Move githubCache out of the durable state file into a quit-time sidecar Every PR/issue poll restamps fetchedAt even when nothing changed, which defeated the content-hash write guard by construction: ~100KB of refetchable 5-min-TTL cache rewrote the whole 1.6MB orca-data.json once per refresh cycle - the last remaining non-user-driven writer. The cache is now memory-only during the session (setGitHubCache no longer schedules a save; the durable payload and state hash both omit it) and is snapshotted best-effort to orca-github-cache.json at flush (quit/reload), so PR/issue badges still paint instantly on next launch. A legacy in-file cache is used as the seed and stripped by the next durable write; downgrades just recreate the key, which the next upgrade strips again. Sidecar loss costs nothing - the data is refetchable. Co-authored-by: Orca * Reject null/array pr-issue shapes when reading the github cache sidecar A corrupt-but-valid-JSON sidecar ({"pr":null}) would seed state.githubCache.pr = null and crash mobile worktree.ps PR grouping until the renderer's first cache save repaired it. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/persistence.test.ts | 43 ++++++++++++++++++ src/main/persistence.ts | 88 ++++++++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b1856b1a8c4..60ab8fb3a19 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4584,6 +4584,49 @@ describe('Store', () => { expect(statSync(dataFile()).ino).toBe(inoBefore) }) + // ── GitHub cache sidecar ─────────────────────────────────────────── + + it('cache refreshes never rewrite the durable state file', async () => { + vi.useFakeTimers() + try { + const store = await createStore() + store.updateUI({ sidebarWidth: 411 }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + const inoBefore = statSync(dataFile()).ino + expect((readDataFile() as { githubCache?: unknown }).githubCache).toBeUndefined() + + store.setGitHubCache({ pr: { 'o/r#1': { fetchedAt: 123 } as never }, issue: {} }) + vi.advanceTimersByTime(6000) + await store.waitForPendingWrite() + + expect(statSync(dataFile()).ino).toBe(inoBefore) + } finally { + vi.useRealTimers() + } + }) + + it('snapshots the cache at flush and seeds the next Store from the sidecar', async () => { + const store = await createStore() + store.setGitHubCache({ pr: { 'o/r#7': { fetchedAt: 7 } as never }, issue: {} }) + store.flush() + expect(existsSync(join(testState.dir, 'orca-github-cache.json'))).toBe(true) + + const restarted = await createStore() + expect(restarted.getGitHubCache().pr['o/r#7']).toEqual({ fetchedAt: 7 }) + }) + + it('keeps a legacy in-file cache as the seed and strips it from disk', async () => { + writeDataFile({ githubCache: { pr: { legacy: { fetchedAt: 1 } }, issue: {} } }) + + const store = await createStore() + expect(store.getGitHubCache().pr.legacy).toEqual({ fetchedAt: 1 }) + + // The legacy key marks the state dirty at load; the next write drops it. + store.flush() + expect((readDataFile() as { githubCache?: unknown }).githubCache).toBeUndefined() + }) + // ── UI state ─────────────────────────────────────────────────────── it('updateUI merges partial updates', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 5d9ef610f48..fb25286488c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -325,6 +325,34 @@ function getDataFile(): string { return _dataFile } +// Why a sidecar: githubCache is a refetchable 5-min-TTL poll cache whose +// fetchedAt stamps change on every refresh — keeping it inside orca-data.json +// made every poll cycle rewrite the whole multi-MB durable state (defeating +// the content-hash guard by design). It lives in memory during the session +// and is snapshotted here best-effort at quit so PR/issue badges still paint +// instantly on the next launch. Loss of this file costs nothing. +function getGithubCacheFile(): string { + return join(dirname(getDataFile()), 'orca-github-cache.json') +} + +function readGithubCacheSnapshot(): PersistedState['githubCache'] | null { + try { + const parsed = JSON.parse(readFileSync(getGithubCacheFile(), 'utf-8')) as unknown + const isPlainRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + if ( + isPlainRecord(parsed) && + isPlainRecord((parsed as { pr?: unknown }).pr) && + isPlainRecord((parsed as { issue?: unknown }).issue) + ) { + return parsed as PersistedState['githubCache'] + } + } catch { + // Missing or corrupt snapshot: start with an empty cache and refetch. + } + return null +} + /** * Return the userData directory captured at initDataPath() time, before * app.setName() can change how app.getPath('userData') resolves. @@ -2451,6 +2479,7 @@ export class Store { // on-disk bytes differ even for identical state. private lastWrittenStateHash: string | null = null private firstPendingSaveAt: number | null = null + private githubCacheDirty = false private gitUsernameCache = new Map() private loadNeedsSave = false private settingsChangeListeners = new Set< @@ -3294,6 +3323,24 @@ export class Store { result = folderScopeConnectionMigration.state const migrated = this.migrateTelemetry(result, fileExistedOnLoad) + + // githubCache lives in a sidecar file now (see getGithubCacheFile). A + // legacy in-file cache (pre-sidecar build, or a downgrade round-trip) is + // kept as this session's seed and stripped from the durable file by the + // save scheduled below; otherwise seed from the sidecar snapshot. + const legacyCache = migrated.githubCache + const hasLegacyCache = + Object.keys(legacyCache?.pr ?? {}).length > 0 || + Object.keys(legacyCache?.issue ?? {}).length > 0 + if (hasLegacyCache) { + this.loadNeedsSave = true + // Why: mark dirty so the first flush writes the sidecar even if no + // poll refresh happens this session — the seed survives the migration. + this.githubCacheDirty = true + } else { + migrated.githubCache = readGithubCacheSnapshot() ?? migrated.githubCache + } + logPersistenceStartupMilestone('persistence-load-done', { repos: migrated.repos.length, workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) @@ -3403,8 +3450,16 @@ export class Store { } } + // Why githubCache is omitted: it is memory-only during the session (see + // getGithubCacheFile) — excluding it from both the payload and the hash + // keeps cache refreshes from ever touching the durable file. + private getDurableState(): Omit { + const { githubCache: _memoryOnly, ...durable } = this.state + return durable + } + private computeStateHash(): string { - return createHash('sha1').update(JSON.stringify(this.state)).digest('hex') + return createHash('sha1').update(JSON.stringify(this.getDurableState())).digest('hex') } // Why: builds the on-disk payload synchronously so the hash and the @@ -3414,7 +3469,7 @@ export class Store { // Why: secrets must be encrypted on disk. Clone state so the in-memory // this.state stays plaintext for the rest of the app. const stateToSave = { - ...this.state, + ...this.getDurableState(), settings: { ...this.state.settings, opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie), @@ -5241,8 +5296,12 @@ export class Store { } setGitHubCache(cache: PersistedState['githubCache']): void { + // Why no scheduleSave: the cache is memory-only during the session and + // snapshotted to its sidecar file at flush (quit/reload) time. Every poll + // refresh restamps fetchedAt, so persisting here rewrote the whole + // durable state file once per poll cycle for refetchable data. this.state.githubCache = cache - this.scheduleSave() + this.githubCacheDirty = true } // ── Workspace Session ───────────────────────────────────────────── @@ -5947,6 +6006,29 @@ export class Store { } catch (err) { console.error('[persistence] Failed to flush state:', err) } + this.writeGithubCacheSnapshotSync() + } + + // Why best-effort: the sidecar is a refetchable cache — a failed write only + // costs a cold badge paint on next launch, never data. + private writeGithubCacheSnapshotSync(): void { + if (!this.githubCacheDirty) { + return + } + const cacheFile = getGithubCacheFile() + const tmpFile = `${cacheFile}.${process.pid}.tmp` + try { + writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') + renameSync(tmpFile, cacheFile) + this.githubCacheDirty = false + } catch (err) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort cleanup. + } + console.warn('[persistence] Failed to write github cache snapshot:', err) + } } } From ad13532ab1f8ea31fc434da4d503ba0c93260fb3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:12:26 -0700 Subject: [PATCH 07/70] Garbage-collect stale worktreeMeta for externally-deleted worktrees (#7107) * Garbage-collect stale worktreeMeta for externally-deleted worktrees at load worktreeMeta rows are minted for every worktree Orca ever discovers but were only deleted by Orca-initiated removal flows. Worktrees deleted outside Orca (git worktree remove, rm -rf, agent scripts) purge renderer session state without ever removing the meta, so the map grew monotonically - 318 of 503 entries (63%, ~110KB) on the reference machine pointed at long-deleted paths. These entries never reach the renderer; they only cost disk bytes, parse time, and memory. GC at load is deliberately narrow: local-host entries only (SSH/runtime repos, remote meta.hostId, and WSL UNC paths are skipped - a local existsSync would falsely condemn remote paths), only when the checkout path no longer exists, only after a 30-day idle grace (preserving pushTarget cleanup for recently-vanished worktrees and quick recreations), and never when the entry lacks timestamps to prove idleness. Lineage rows cascade like removeWorktreeMeta. Co-authored-by: Orca * Fix GC false positives: folder-workspace instances, WSL paths on Windows From adversarial review of the GC commit: - Folder-project workspace instances are keyed repoId::path::workspace: ; splitting on the first :: made the existence probe test a path-with-suffix that never exists, deleting idle folder workspaces - whose meta IS the workspace record - and cascading into terminal- history deletion. Instance keys are now skipped entirely. - On Windows, WSL linked worktrees carry Linux-style paths from git porcelain that a Windows existsSync cannot probe; skip non-Windows paths on win32. - Idle-grace check now runs before existsSync (skips the stat fan-out for active entries and shrinks the slow-NFS startup tail), and a null worktreeMeta map in the file is normalized instead of throwing outside the parse guard. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/persistence.test.ts | 86 +++++++++++++++++++++++++++++++++++ src/main/persistence.ts | 88 +++++++++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 60ab8fb3a19..419016f6f83 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4584,6 +4584,92 @@ describe('Store', () => { expect(statSync(dataFile()).ino).toBe(inoBefore) }) + // ── worktreeMeta startup GC ──────────────────────────────────────── + + it('garbage-collects stale local worktreeMeta at load with a 30-day grace', async () => { + const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000 + const RECENT = Date.now() - 1 * 24 * 60 * 60 * 1000 + const missing = (name: string): string => join(testState.dir, 'gone', name) + const meta = (lastActivityAt: number, extra: Record = {}) => ({ + displayName: '', + comment: '', + lastActivityAt, + ...extra + }) + const liveKey = `r1::${testState.dir}` + const deadKey = `r1::${missing('dead')}` + const recentKey = `r1::${missing('recent')}` + const sshKey = `ssh-repo::/home/alice/gone` + const remoteHostKey = `r1::${missing('remote-host')}` + const orphanKey = `removed-repo::${missing('orphan')}` + const wslKey = `r1::\\\\wsl$\\Ubuntu\\home\\gone` + + writeDataFile({ + repos: [ + makeRepo(), + makeRepo({ id: 'ssh-repo', path: '/home/alice/repo', connectionId: 'conn-1' }) + ], + worktreeMeta: { + [liveKey]: meta(OLD), + [deadKey]: meta(OLD), + [recentKey]: meta(RECENT), + [sshKey]: meta(OLD), + [remoteHostKey]: meta(OLD, { hostId: 'ssh:conn-1' }), + [orphanKey]: meta(OLD), + [wslKey]: meta(OLD) + }, + worktreeLineageById: { [deadKey]: { parentWorktreeId: liveKey } } + }) + + const store = await createStore() + const kept = Object.keys(store.getAllWorktreeMeta()) + + expect(kept).toContain(liveKey) // path exists + expect(kept).toContain(recentKey) // inside the grace window + expect(kept).toContain(sshKey) // SSH repo: remote paths never checked locally + expect(kept).toContain(remoteHostKey) // remote hostId on the meta itself + expect(kept).toContain(wslKey) // WSL UNC path + expect(kept).not.toContain(deadKey) + expect(kept).not.toContain(orphanKey) + expect(store.getWorktreeLineage(deadKey)).toBeUndefined() + }) + + it('never GCs folder-workspace instance metas — the meta IS the workspace', async () => { + const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000 + const folderInstanceKey = `r1::${join(testState.dir, 'gone-folder')}::workspace:11111111-1111-4111-8111-111111111111` + writeDataFile({ + repos: [makeRepo({ kind: 'folder' })], + worktreeMeta: { + [folderInstanceKey]: { displayName: 'Session A', comment: '', lastActivityAt: OLD } + } + }) + + const store = await createStore() + expect(Object.keys(store.getAllWorktreeMeta())).toContain(folderInstanceKey) + }) + + it('never GCs Linux-style WSL worktree paths on Windows', async () => { + const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000 + const wslLinkedKey = 'r1::/home/user/gone-worktree' + writeDataFile({ + repos: [makeRepo()], + worktreeMeta: { + [wslLinkedKey]: { displayName: '', comment: '', lastActivityAt: OLD } + } + }) + + await withPlatform('win32', async () => { + const store = await createStore() + expect(Object.keys(store.getAllWorktreeMeta())).toContain(wslLinkedKey) + }) + }) + + it('tolerates a null worktreeMeta map in the durable file', async () => { + writeDataFile({ worktreeMeta: null }) + const store = await createStore() + expect(store.getAllWorktreeMeta()).toEqual({}) + }) + // ── GitHub cache sidecar ─────────────────────────────────────────── it('cache refreshes never rewrite the durable state file', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index fb25286488c..14f3da845bc 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -112,6 +112,7 @@ import { type ExecutionHostId } from '../shared/execution-host' import { toRelaySshPtyId } from './providers/ssh-pty-id' +import { isWslUncPath } from '../shared/wsl-paths' import { isTerminalLeafId, makePaneKey, @@ -125,9 +126,14 @@ import { import { agentHookServer } from './agent-hooks/server' import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers' import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history' -import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../shared/worktree-id' +import { + FOLDER_WORKSPACE_INSTANCE_SEPARATOR, + getRepoIdFromWorktreeId, + getWorktreePathBasenameFromId +} from '../shared/worktree-id' import { isPathInsideOrEqual, + isWindowsAbsolutePathLike, normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands' @@ -335,6 +341,82 @@ function getGithubCacheFile(): string { return join(dirname(getDataFile()), 'orca-github-cache.json') } +// Why: worktrees deleted outside Orca (git CLI worktree remove, rm -rf, +// agent scripts) purge renderer session state but nothing removed their +// worktreeMeta, so the map grew monotonically (63% dead entries measured on +// a heavy install). GC is deliberately narrow: local-host entries only +// (SSH/runtime metas embed remote paths a local existsSync would falsely +// condemn; WSL UNC paths are skipped the same way), and only after a +// 30-day idle grace so pushTarget cleanup for recently-vanished worktrees +// and quick recreations keep their metadata. +const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000 + +function gcStaleWorktreeMeta(state: PersistedState): number { + // Why: a hand-corrupted file with `"worktreeMeta": null` overrides the + // defaults merge; normalize instead of throwing outside the parse guard. + state.worktreeMeta ??= {} + const repoById = new Map(state.repos.map((repo) => [repo.id, repo])) + const projectIds = new Set((state.projects ?? []).map((project) => project.id)) + const now = Date.now() + let removed = 0 + for (const key of Object.keys(state.worktreeMeta)) { + // Why: folder-project workspace instances are keyed + // `repoId::path::workspace:` and their meta IS the workspace + // record — never a filesystem-checkout row. Skip them entirely. + if (key.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR)) { + continue + } + const separator = key.indexOf('::') + if (separator === -1) { + continue + } + const ownerId = key.slice(0, separator) + const worktreePath = key.slice(separator + 2) + const meta = state.worktreeMeta[key] + const repo = repoById.get(ownerId) + if (repo) { + if (repo.connectionId || getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { + continue + } + } else if (projectIds.has(ownerId)) { + // Project-owned metas keep project/host semantics on the entry itself; + // stay conservative and leave them to their own lifecycle. + continue + } + // Unowned entries (repo removed before removeProject pruned metas) fall + // through to the same missing-path + idle-grace gate. + if (meta?.hostId && meta.hostId !== LOCAL_EXECUTION_HOST_ID) { + continue + } + if (!isAbsolute(worktreePath) || isWslUncPath(worktreePath)) { + continue + } + // Why: WSL linked worktrees on Windows carry Linux-style paths from git + // porcelain; a Windows existsSync cannot probe those and would falsely + // condemn live worktrees. + if (process.platform === 'win32' && !isWindowsAbsolutePathLike(worktreePath)) { + continue + } + // Why keep timestamp-less entries: without lastActivityAt/createdAt we + // cannot prove the 30-day idle grace elapsed; the measured dead entries + // all carry timestamps, so this costs almost nothing in reclaimed bytes. + // Grace runs before the stat so healthy profiles skip the existsSync + // fan-out (and its slow-NFS tail) for active entries entirely. + const newestTouch = Math.max(meta?.lastActivityAt ?? 0, meta?.createdAt ?? 0) + if (newestTouch === 0 || now - newestTouch < WORKTREE_META_GC_GRACE_MS) { + continue + } + if (existsSync(worktreePath)) { + continue + } + delete state.worktreeMeta[key] + delete state.worktreeLineageById[key] + delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)] + removed++ + } + return removed +} + function readGithubCacheSnapshot(): PersistedState['githubCache'] | null { try { const parsed = JSON.parse(readFileSync(getGithubCacheFile(), 'utf-8')) as unknown @@ -3322,6 +3404,10 @@ export class Store { } result = folderScopeConnectionMigration.state + if (gcStaleWorktreeMeta(result) > 0) { + this.loadNeedsSave = true + } + const migrated = this.migrateTelemetry(result, fileExistedOnLoad) // githubCache lives in a sidecar file now (see getGithubCacheFile). A From dbf80864feee7d769fdbd0d2d5cb115f0fa02d78 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:58:11 +0000 Subject: [PATCH 08/70] release: v1.4.118-rc.0 [rc-slot:2026-07-02-03] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 88f598a7d08..3f8d4cf4f96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.117-rc.3", + "version": "1.4.118-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 85cec26773196cfd2d08923f412150cfa76ade9f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:54:19 -0700 Subject: [PATCH 09/70] Prevent unsigned Windows app releases (#6806) * Verify Windows app executable signing * Isolate Windows signing verifier tests * Handle direct Windows installer extraction --------- Co-authored-by: Neil --- .github/workflows/release-cut.yml | 45 ++++ ...package-electron-runtime-contract.test.mjs | 47 ++++ .../verify-windows-inner-signature.mjs | 200 +++++++++++++++++ .../verify-windows-inner-signature.test.mjs | 211 ++++++++++++++++++ 4 files changed, 503 insertions(+) create mode 100644 config/scripts/verify-windows-inner-signature.mjs create mode 100644 config/scripts/verify-windows-inner-signature.test.mjs diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index df152e9fb31..957ae1704fa 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1062,6 +1062,51 @@ jobs: } $signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint + - name: Verify signed Windows inner executable + if: matrix.platform == 'win' + shell: pwsh + env: + ORCA_WINDOWS_EXPECTED_SIGNERS: CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US;CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, ST=Delaware, C=US + run: | + # Why: SignPath must recursively sign nested PE files in the dashboard + # artifact configuration; this gate keeps unsigned app payloads out of releases. + $installer = Join-Path -Path (Get-Location) -ChildPath 'dist/orca-windows-setup.exe' + $setupExtractDir = Join-Path -Path $env:RUNNER_TEMP -ChildPath 'orca-signed-setup' + $appExtractDir = Join-Path -Path $env:RUNNER_TEMP -ChildPath 'orca-signed-app' + + Remove-Item -LiteralPath $setupExtractDir, $appExtractDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $setupExtractDir, $appExtractDir -Force | Out-Null + + & 7z x -y "-o$setupExtractDir" $installer + if ($LASTEXITCODE -ne 0) { + throw "Failed to extract signed Windows installer with 7z. Exit code: $LASTEXITCODE" + } + + # Why: 7-Zip can expose electron-builder NSIS payloads either as an + # app-root tree directly or as a nested app-*.7z archive. + $innerExecutables = @(Get-ChildItem -LiteralPath $setupExtractDir -File -Filter 'Orca.exe') + if ($innerExecutables.Count -eq 0) { + $appArchives = @(Get-ChildItem -LiteralPath $setupExtractDir -Recurse -File -Filter 'app-*.7z') + if ($appArchives.Count -ne 1) { + $matches = ($appArchives | ForEach-Object { $_.FullName }) -join [Environment]::NewLine + throw "Expected app-root Orca.exe or exactly one app-*.7z payload in signed Windows installer; found $($appArchives.Count) app archive(s).$([Environment]::NewLine)$matches" + } + + & 7z x -y "-o$appExtractDir" $($appArchives[0].FullName) + if ($LASTEXITCODE -ne 0) { + throw "Failed to extract signed Windows app payload with 7z. Exit code: $LASTEXITCODE" + } + + $innerExecutables = @(Get-ChildItem -LiteralPath $appExtractDir -File -Filter 'Orca.exe') + } + + if ($innerExecutables.Count -ne 1) { + $matches = ($innerExecutables | ForEach-Object { $_.FullName }) -join [Environment]::NewLine + throw "Expected exactly one app-root Orca.exe in signed Windows app payload; found $($innerExecutables.Count).$([Environment]::NewLine)$matches" + } + + node config/scripts/verify-windows-inner-signature.mjs $($innerExecutables[0].FullName) + - name: Publish signed Windows release artifacts if: matrix.platform == 'win' uses: nick-fields/retry@v4 diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 5ee9f1a3017..d3b3e1226a6 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -217,6 +217,53 @@ describe('Electron runtime package contract', () => { expect(installRun).not.toMatch(/throw\s+\$_/) }) + it('verifies the SignPath-signed Windows inner executable before publishing', () => { + const releaseWorkflow = readFileSync( + join(projectDir, '.github/workflows/release-cut.yml'), + 'utf8' + ) + const parsedWorkflow = parse(releaseWorkflow) + const steps = parsedWorkflow.jobs.build.steps + const stepNames = steps.map((step) => step.name) + const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer') + const innerVerifyIndex = stepNames.indexOf('Verify signed Windows inner executable') + const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts') + + expect(outerVerifyIndex).toBeGreaterThan(-1) + expect(innerVerifyIndex).toBe(outerVerifyIndex + 1) + expect(innerVerifyIndex).toBeLessThan(publishIndex) + + const innerVerifyStep = steps[innerVerifyIndex] + const innerVerifyRun = innerVerifyStep.run + + expect(innerVerifyStep.if).toBe("matrix.platform == 'win'") + expect(innerVerifyStep.shell).toBe('pwsh') + expect(innerVerifyStep.env.ORCA_WINDOWS_EXPECTED_SIGNERS).toBe( + 'CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US;CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, ST=Delaware, C=US' + ) + expect(innerVerifyRun).toContain('& 7z x -y "-o$setupExtractDir" $installer') + expect(innerVerifyRun).toContain( + "$innerExecutables = @(Get-ChildItem -LiteralPath $setupExtractDir -File -Filter 'Orca.exe')" + ) + expect(innerVerifyRun).toContain('if ($innerExecutables.Count -eq 0)') + expect(innerVerifyRun).toContain( + "$appArchives = @(Get-ChildItem -LiteralPath $setupExtractDir -Recurse -File -Filter 'app-*.7z')" + ) + expect(innerVerifyRun).toContain('if ($appArchives.Count -ne 1)') + expect(innerVerifyRun).toContain('Expected app-root Orca.exe or exactly one app-*.7z') + expect(innerVerifyRun).toContain('& 7z x -y "-o$appExtractDir" $($appArchives[0].FullName)') + expect(innerVerifyRun).toContain( + "$innerExecutables = @(Get-ChildItem -LiteralPath $appExtractDir -File -Filter 'Orca.exe')" + ) + expect(innerVerifyRun.indexOf("-Filter 'Orca.exe'")).toBeLessThan( + innerVerifyRun.indexOf("-Filter 'app-*.7z'") + ) + expect(innerVerifyRun).toContain('if ($innerExecutables.Count -ne 1)') + expect(innerVerifyRun).toContain( + 'node config/scripts/verify-windows-inner-signature.mjs $($innerExecutables[0].FullName)' + ) + }) + it('publishes both Linux release matrix entries', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), diff --git a/config/scripts/verify-windows-inner-signature.mjs b/config/scripts/verify-windows-inner-signature.mjs new file mode 100644 index 00000000000..0f3520c1dee --- /dev/null +++ b/config/scripts/verify-windows-inner-signature.mjs @@ -0,0 +1,200 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, statSync } from 'node:fs' +import { pathToFileURL } from 'node:url' + +export const DEFAULT_EXPECTED_SIGNER = + 'CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US' + +const POWERSHELL_SIGNATURE_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$signature = Get-AuthenticodeSignature -FilePath $args[0] +$certificate = $signature.SignerCertificate +[pscustomobject]@{ + status = $signature.Status.ToString() + statusMessage = $signature.StatusMessage + signerSubject = if ($null -eq $certificate) { $null } else { $certificate.Subject } + signerIssuer = if ($null -eq $certificate) { $null } else { $certificate.Issuer } + signerThumbprint = if ($null -eq $certificate) { $null } else { $certificate.Thumbprint } + notBefore = if ($null -eq $certificate) { $null } else { $certificate.NotBefore.ToString('o') } + notAfter = if ($null -eq $certificate) { $null } else { $certificate.NotAfter.ToString('o') } +} | ConvertTo-Json -Compress +` + +export function normalizeSignerSubject(subject) { + if (typeof subject !== 'string') { + return '' + } + + return subject + .split(',') + .map((part) => part.trim().replace(/\s*=\s*/u, '=')) + .filter(Boolean) + .join(', ') +} + +export function normalizeThumbprint(thumbprint) { + if (typeof thumbprint !== 'string') { + return '' + } + + return thumbprint.replace(/[^0-9a-f]/giu, '').toUpperCase() +} + +export function parseExpectedSigners(value = process.env.ORCA_WINDOWS_EXPECTED_SIGNERS) { + const source = typeof value === 'string' && value.trim() !== '' ? value : DEFAULT_EXPECTED_SIGNER + + return source + .split(/[\r\n;]+/u) + .map(normalizeSignerSubject) + .filter(Boolean) +} + +export function parseExpectedThumbprints(value = process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS) { + if (typeof value !== 'string' || value.trim() === '') { + return [] + } + + return value + .split(/[\r\n,;]+/u) + .map(normalizeThumbprint) + .filter(Boolean) +} + +export function parseSignatureJson(stdout) { + const trimmed = typeof stdout === 'string' ? stdout.trim() : '' + if (trimmed === '') { + throw new Error('PowerShell did not return signature JSON.') + } + + try { + return JSON.parse(trimmed) + } catch (error) { + throw new Error(`PowerShell returned malformed signature JSON: ${error.message}`) + } +} + +export function classifySignature(signature, options = {}) { + const expectedSigners = options.expectedSigners ?? parseExpectedSigners() + const expectedThumbprints = options.expectedThumbprints ?? parseExpectedThumbprints() + const status = typeof signature?.status === 'string' ? signature.status : '' + const signerSubject = normalizeSignerSubject(signature?.signerSubject) + const signerThumbprint = normalizeThumbprint(signature?.signerThumbprint) + const subjectAllowed = expectedSigners.includes(signerSubject) + const thumbprintAllowed = + expectedThumbprints.length > 0 && + signerThumbprint !== '' && + expectedThumbprints.includes(signerThumbprint) + + if (status !== 'Valid') { + return { + ok: false, + message: `Windows inner executable signature status is ${status || ''}.`, + signature + } + } + + if (!subjectAllowed && !thumbprintAllowed) { + return { + ok: false, + message: `Unexpected Windows inner executable signer: ${signerSubject || ''}.`, + signature + } + } + + return { ok: true, signature } +} + +export function formatSignatureSummary(signature) { + return [ + `Status: ${signature.status ?? ''}`, + `Subject: ${normalizeSignerSubject(signature.signerSubject) || ''}`, + `Issuer: ${signature.signerIssuer ?? ''}`, + `Thumbprint: ${normalizeThumbprint(signature.signerThumbprint) || ''}`, + `NotBefore: ${signature.notBefore ?? ''}`, + `NotAfter: ${signature.notAfter ?? ''}` + ].join('\n') +} + +export function validateExecutablePath(executablePath) { + if (typeof executablePath !== 'string' || executablePath.trim() === '') { + throw new Error('Usage: node config/scripts/verify-windows-inner-signature.mjs ') + } + + if (!existsSync(executablePath)) { + throw new Error(`Windows inner executable does not exist: ${executablePath}`) + } + + if (!statSync(executablePath).isFile()) { + throw new Error(`Windows inner executable path is not a file: ${executablePath}`) + } +} + +export function getPowerShellSignatureJson(executablePath, spawnSyncImpl = spawnSync) { + const result = spawnSyncImpl( + 'pwsh', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + POWERSHELL_SIGNATURE_SCRIPT, + executablePath + ], + { encoding: 'utf8' } + ) + + if (result.error) { + throw result.error + } + + if (result.stderr?.trim()) { + throw new Error(`PowerShell wrote to stderr while checking signature:\n${result.stderr.trim()}`) + } + + if (result.status !== 0) { + throw new Error( + `PowerShell signature check failed with exit code ${result.status ?? ''}.` + ) + } + + return result.stdout +} + +export function verifyWindowsInnerSignature({ + executablePath, + platform = process.platform, + spawnSyncImpl = spawnSync, + expectedSigners = parseExpectedSigners(), + expectedThumbprints = parseExpectedThumbprints() +}) { + validateExecutablePath(executablePath) + + if (platform !== 'win32') { + throw new Error('Windows inner executable signature verification requires Windows.') + } + + const signature = parseSignatureJson(getPowerShellSignatureJson(executablePath, spawnSyncImpl)) + const classification = classifySignature(signature, { expectedSigners, expectedThumbprints }) + if (!classification.ok) { + throw new Error(`${classification.message}\n${formatSignatureSummary(signature)}`) + } + + return signature +} + +export function main(argv = process.argv.slice(2)) { + try { + const signature = verifyWindowsInnerSignature({ executablePath: argv[0] }) + console.log('Verified Windows inner executable signature.') + console.log(formatSignatureSummary(signature)) + } catch (error) { + console.error(error.message) + process.exitCode = 1 + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/config/scripts/verify-windows-inner-signature.test.mjs b/config/scripts/verify-windows-inner-signature.test.mjs new file mode 100644 index 00000000000..aa0e1e7ae12 --- /dev/null +++ b/config/scripts/verify-windows-inner-signature.test.mjs @@ -0,0 +1,211 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + DEFAULT_EXPECTED_SIGNER, + classifySignature, + getPowerShellSignatureJson, + normalizeSignerSubject, + normalizeThumbprint, + parseExpectedSigners, + parseExpectedThumbprints, + parseSignatureJson, + validateExecutablePath, + verifyWindowsInnerSignature +} from './verify-windows-inner-signature.mjs' + +const validSignature = { + status: 'Valid', + statusMessage: 'Signature verified.', + signerSubject: DEFAULT_EXPECTED_SIGNER, + signerIssuer: 'CN=SignPath Foundation Root', + signerThumbprint: 'aa bb cc dd', + notBefore: '2026-01-01T00:00:00.0000000Z', + notAfter: '2027-01-01T00:00:00.0000000Z' +} + +function withTempFile(callback) { + const dir = mkdtempSync(join(tmpdir(), 'orca-inner-signature-')) + const filePath = join(dir, 'Orca.exe') + writeFileSync(filePath, 'placeholder executable') + + try { + return callback(filePath, dir) + } finally { + rmSync(dir, { force: true, recursive: true }) + } +} + +describe('verify-windows-inner-signature', () => { + const originalExpectedSigners = process.env.ORCA_WINDOWS_EXPECTED_SIGNERS + const originalExpectedThumbprints = process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS + + beforeEach(() => { + delete process.env.ORCA_WINDOWS_EXPECTED_SIGNERS + delete process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS + }) + + afterEach(() => { + if (originalExpectedSigners === undefined) { + delete process.env.ORCA_WINDOWS_EXPECTED_SIGNERS + } else { + process.env.ORCA_WINDOWS_EXPECTED_SIGNERS = originalExpectedSigners + } + + if (originalExpectedThumbprints === undefined) { + delete process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS + } else { + process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS = originalExpectedThumbprints + } + }) + + it('normalizes signer subjects without widening exact matching', () => { + expect( + normalizeSignerSubject( + ' CN = SignPath Foundation , O=SignPath Foundation,L=Lewes,S=Delaware,C=US ' + ) + ).toBe(DEFAULT_EXPECTED_SIGNER) + expect( + normalizeSignerSubject( + 'CN=Different Signer, O=SignPath Foundation, L=Lewes, S=Delaware, C=US' + ) + ).not.toBe(DEFAULT_EXPECTED_SIGNER) + }) + + it('defaults the expected signer allowlist to SignPath Foundation', () => { + expect(parseExpectedSigners('')).toEqual([DEFAULT_EXPECTED_SIGNER]) + expect(parseExpectedSigners(' ')).toEqual([DEFAULT_EXPECTED_SIGNER]) + }) + + it('parses semicolon and newline separated signer allowlists', () => { + expect(parseExpectedSigners(`${DEFAULT_EXPECTED_SIGNER};\nCN=Backup, O=Backup`)).toEqual([ + DEFAULT_EXPECTED_SIGNER, + 'CN=Backup, O=Backup' + ]) + }) + + it('normalizes optional thumbprint allowlists', () => { + expect(normalizeThumbprint('aa bb:cc')).toBe('AABBCC') + expect(parseExpectedThumbprints('aa bb cc,11:22:33')).toEqual(['AABBCC', '112233']) + }) + + it('rejects missing, nonexistent, and directory executable paths before PowerShell', () => { + expect(() => validateExecutablePath('')).toThrow(/Usage:/) + expect(() => validateExecutablePath(join(tmpdir(), 'missing-Orca.exe'))).toThrow( + /does not exist/ + ) + + withTempFile((filePath, dir) => { + expect(() => validateExecutablePath(dir)).toThrow(/not a file/) + expect(() => validateExecutablePath(filePath)).not.toThrow() + }) + }) + + it('parses the exact JSON emitted by PowerShell', () => { + expect(parseSignatureJson(JSON.stringify(validSignature))).toEqual(validSignature) + expect(() => parseSignatureJson('')).toThrow(/did not return/) + expect(() => parseSignatureJson(`${JSON.stringify(validSignature)}\nextra`)).toThrow( + /malformed/ + ) + }) + + it('accepts a valid signature with an exact normalized signer subject', () => { + const result = classifySignature({ + ...validSignature, + signerSubject: ' CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US ' + }) + + expect(result.ok).toBe(true) + }) + + it('rejects invalid status and unexpected signer subjects', () => { + expect(classifySignature({ ...validSignature, status: 'NotSigned' }).message).toMatch( + /status is NotSigned/ + ) + expect( + classifySignature({ + ...validSignature, + signerSubject: 'CN=SignPath Foundation Test, O=SignPath Foundation, L=Vienna, C=AT' + }).message + ).toMatch(/Unexpected Windows inner executable signer/) + }) + + it('accepts an expected thumbprint as an alternate explicit allowlist', () => { + expect(classifySignature({ ...validSignature, signerThumbprint: '00' }).ok).toBe(true) + expect( + classifySignature( + { ...validSignature, signerSubject: 'CN=Rotated Signer, O=Rotated' }, + { + expectedSigners: [DEFAULT_EXPECTED_SIGNER], + expectedThumbprints: ['AABBCCDD'] + } + ).ok + ).toBe(true) + expect( + classifySignature( + { ...validSignature, signerSubject: 'CN=Rotated Signer, O=Rotated' }, + { + expectedSigners: [DEFAULT_EXPECTED_SIGNER], + expectedThumbprints: ['001122'] + } + ).message + ).toMatch(/Unexpected Windows inner executable signer/) + expect( + classifySignature(validSignature, { + expectedSigners: [DEFAULT_EXPECTED_SIGNER], + expectedThumbprints: ['001122'] + }).ok + ).toBe(true) + }) + + it('runs PowerShell with an argument array and fails on stderr or nonzero exit', () => { + const calls = [] + const spawnSyncImpl = (command, args, options) => { + calls.push({ command, args, options }) + return { status: 0, stdout: JSON.stringify(validSignature), stderr: '' } + } + + expect(getPowerShellSignatureJson('C:\\Path With Spaces\\Orca.exe', spawnSyncImpl)).toBe( + JSON.stringify(validSignature) + ) + expect(calls[0].command).toBe('pwsh') + expect(calls[0].args).toContain('-Command') + expect(calls[0].args.at(-1)).toBe('C:\\Path With Spaces\\Orca.exe') + expect(calls[0].options).toEqual({ encoding: 'utf8' }) + + expect(() => + getPowerShellSignatureJson('Orca.exe', () => ({ status: 0, stdout: '{}', stderr: 'warning' })) + ).toThrow(/stderr/) + expect(() => + getPowerShellSignatureJson('Orca.exe', () => ({ status: 7, stdout: '', stderr: '' })) + ).toThrow(/exit code 7/) + }) + + it('verifies with injected Windows platform and spawn implementation', () => { + withTempFile((filePath) => { + const signature = verifyWindowsInnerSignature({ + executablePath: filePath, + platform: 'win32', + spawnSyncImpl: () => ({ status: 0, stdout: JSON.stringify(validSignature), stderr: '' }) + }) + + expect(signature).toEqual(validSignature) + }) + }) + + it('does not attempt real Authenticode verification outside Windows', () => { + withTempFile((filePath) => { + expect(() => + verifyWindowsInnerSignature({ + executablePath: filePath, + platform: 'linux', + spawnSyncImpl: () => { + throw new Error('should not spawn') + } + }) + ).toThrow(/requires Windows/) + }) + }) +}) From 1ac6de1ccb9e9348c2a0b90b0faa794f9b13fcad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:01:05 +0000 Subject: [PATCH 10/70] release: v1.4.118-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3f8d4cf4f96..7c5842845da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.118-rc.0", + "version": "1.4.118-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 4c03924618dfc444bfcf6ff7824604ea7bbfe271 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:24:07 -0700 Subject: [PATCH 11/70] Show Git-created worktrees in external discovery (#7078) --- src/main/ipc/worktrees.ts | 7 +- src/main/runtime/orca-runtime.ts | 7 +- src/main/worktree-removal-authority.test.ts | 49 +++++----- src/main/worktree-removal-safety.test.ts | 2 - src/main/worktree-removal-safety.ts | 13 +-- src/shared/external-worktree-inbox.test.ts | 82 +++++++++++++++- ...ktree-ownership-worktree-base-path.test.ts | 4 +- src/shared/worktree-ownership.test.ts | 96 ++++++++++++++++++- src/shared/worktree-ownership.ts | 49 +--------- 9 files changed, 209 insertions(+), 100 deletions(-) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 890449f51b6..c7ff8f45c82 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1402,13 +1402,9 @@ export function registerWorktreeHandlers( if (!registeredWorktree) { const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null let canCleanOrphanedDirectory = false - const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) if ( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: removedMeta, - worktreePath, - repo, - knownOrcaLayouts + meta: removedMeta }) ) { if (repo.connectionId) { @@ -1481,7 +1477,6 @@ export function registerWorktreeHandlers( runtimeWorktreePath, repo, runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - knownOrcaLayouts, registeredWorktrees, statPath: access.statPath, isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 819fb34ea69..69dbea1eb3b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -14713,13 +14713,9 @@ export class OrcaRuntimeService { ) if (!registeredWorktree) { let canCleanOrphanedDirectory = false - const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) if ( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: removedMeta, - worktreePath: removalTarget.path, - repo, - knownOrcaLayouts + meta: removedMeta }) ) { if (repo.connectionId) { @@ -14792,7 +14788,6 @@ export class OrcaRuntimeService { runtimeWorktreePath, repo, runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - knownOrcaLayouts, registeredWorktrees, statPath: access.statPath, isGitRepository: (path) => isLocalRuntimeGitRepository(path, localWorktreeGitOptions) diff --git a/src/main/worktree-removal-authority.test.ts b/src/main/worktree-removal-authority.test.ts index acb8d2f3f3c..cbc9e2ed24c 100644 --- a/src/main/worktree-removal-authority.test.ts +++ b/src/main/worktree-removal-authority.test.ts @@ -4,6 +4,7 @@ import { isWorktreePathMissing, stripOrcaProvenanceMetaUpdates } from './worktree-removal-safety' +import type { WorktreeMeta } from '../shared/types' describe('isWorktreePathMissing', () => { it('recognizes missing-path errors from local and remote stat providers', async () => { @@ -33,10 +34,7 @@ describe('canCleanupUnregisteredOrcaWorktreeDirectory', () => { it('does not treat orcaCreatedAt alone as cleanup authority', () => { expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: { orcaCreatedAt: Date.now() }, - worktreePath: '/outside/orphan', - repo: { path: '/repo' }, - knownOrcaLayouts: [] + meta: { orcaCreatedAt: Date.now() } }) ).toBe(false) expect( @@ -44,10 +42,7 @@ describe('canCleanupUnregisteredOrcaWorktreeDirectory', () => { meta: { orcaCreatedAt: Date.now(), orcaCreationSource: 'runtime' - }, - worktreePath: '/outside/orphan', - repo: { path: '/repo' }, - knownOrcaLayouts: [] + } }) ).toBe(true) }) @@ -55,32 +50,40 @@ describe('canCleanupUnregisteredOrcaWorktreeDirectory', () => { it('accepts legacy Orca-created metadata before explicit provenance existed', () => { expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: { createdAt: Date.now() }, - worktreePath: '/outside/orphan', - repo: { path: '/repo' }, - knownOrcaLayouts: [] + meta: { createdAt: Date.now() } }) ).toBe(true) }) - it('accepts legacy repo-nested Orca workspace paths without metadata provenance', () => { + it('does not treat creation layout metadata alone as cleanup authority', () => { + const layoutOnlyMeta: WorktreeMeta = { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'todo', + orcaCreationWorkspaceLayout: { path: '/orca/workspaces', nestWorkspaces: true } + } + expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: undefined, - worktreePath: '/orca/workspaces/app/legacy-orphan', - repo: { path: '/repos/app' }, - knownOrcaLayouts: [{ path: '/orca/workspaces', nestWorkspaces: true }] + meta: layoutOnlyMeta }) - ).toBe(true) + ).toBe(false) }) - it('does not trust flat workspace-root paths without legacy metadata', () => { + it('does not trust paths without provenance or legacy metadata', () => { expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: undefined, - worktreePath: '/orca/workspaces/legacy-orphan', - repo: { path: '/repos/app' }, - knownOrcaLayouts: [{ path: '/orca/workspaces', nestWorkspaces: false }] + meta: undefined }) ).toBe(false) }) diff --git a/src/main/worktree-removal-safety.test.ts b/src/main/worktree-removal-safety.test.ts index d008b952089..5a64b8c90e5 100644 --- a/src/main/worktree-removal-safety.test.ts +++ b/src/main/worktree-removal-safety.test.ts @@ -325,7 +325,6 @@ describe('canCleanupUnregisteredOrcaLeftoverDirectory', () => { runtimeWorktreePath: '/workspaces/orca-owned', repo, runtimeRepoPath: repo.path, - knownOrcaLayouts: [], registeredWorktrees: [makeGitWorktree(repo.path, true)] } @@ -376,7 +375,6 @@ describe('canCleanupUnregisteredOrcaLeftoverDirectory', () => { canCleanupUnregisteredOrcaLeftoverDirectory({ ...baseArgs, meta: undefined, - knownOrcaLayouts: [{ path: '/workspaces', nestWorkspaces: false }], statPath: makeStatPath([], ['/workspaces/orca-owned']), isGitRepository }) diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index a8236ea4908..a589bce9e10 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -2,8 +2,7 @@ import { lstat, readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { posix, win32 } from 'node:path' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' -import type { GitWorktreeInfo, OrcaWorkspaceLayout, Repo, WorktreeMeta } from '../shared/types' -import { matchesStrongOrcaCreatePath } from '../shared/worktree-ownership' +import type { GitWorktreeInfo, Repo, WorktreeMeta } from '../shared/types' import { areWorktreePathsEqual } from './ipc/worktree-logic' import { gitFileProvesOrphanedWorktreeDirectory, @@ -172,9 +171,6 @@ export async function canSafelyRemoveOrphanedWorktreeDirectory( export function canCleanupUnregisteredOrcaWorktreeDirectory(args: { meta: UnregisteredOrcaCleanupMeta | null | undefined - worktreePath: string - repo: Pick - knownOrcaLayouts: readonly OrcaWorkspaceLayout[] }): boolean { if (hasCurrentOrcaCreationProvenance(args.meta)) { return true @@ -184,9 +180,9 @@ export function canCleanupUnregisteredOrcaWorktreeDirectory(args: { return true } - // Why: profiles created before explicit provenance can still contain Orca - // workspaces at the repo-specific workspaceDir// path shape. - return matchesStrongOrcaCreatePath(args.worktreePath, args.knownOrcaLayouts, args.repo) + // Why: path shape alone is not authority; users can create plain Git + // worktrees inside Orca's workspace directory too. + return false } export async function canCleanupUnregisteredOrcaLeftoverDirectory(args: { @@ -195,7 +191,6 @@ export async function canCleanupUnregisteredOrcaLeftoverDirectory(args: { runtimeWorktreePath: string repo: Pick runtimeRepoPath: string - knownOrcaLayouts: readonly OrcaWorkspaceLayout[] registeredWorktrees: readonly GitWorktreeInfo[] statPath: StatPath isGitRepository: (runtimeWorktreePath: string) => Promise diff --git a/src/shared/external-worktree-inbox.test.ts b/src/shared/external-worktree-inbox.test.ts index 029e04c3959..1e16c6d5134 100644 --- a/src/shared/external-worktree-inbox.test.ts +++ b/src/shared/external-worktree-inbox.test.ts @@ -1,13 +1,23 @@ import { describe, expect, it } from 'vitest' -import type { DetectedWorktree, DetectedWorktreeListResult, Repo } from './types' +import type { + DetectedWorktree, + DetectedWorktreeListResult, + GlobalSettings, + Repo, + Worktree +} from './types' import { getHiddenExternalWorktrees, getNewExternalWorktreeInboxWorktrees, mergeExternalWorktreeInboxPaths, shouldOfferNewExternalWorktreeInbox } from './external-worktree-inbox' -import { EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT } from './worktree-ownership' +import { + buildKnownOrcaWorkspaceLayouts, + EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT, + toDetectedWorktree +} from './worktree-ownership' const repo: Repo = { id: 'repo-1', @@ -54,6 +64,55 @@ function detectedResult(worktrees: DetectedWorktree[]): DetectedWorktreeListResu } } +function makeSettings(): GlobalSettings { + return { + workspaceDir: '/orca/workspaces', + nestWorkspaces: true, + workspaceDirHistory: [], + refreshLocalBaseRefOnWorktreeCreate: false, + localBaseRefSuggestionDismissed: false, + branchPrefix: 'none', + branchPrefixCustom: '', + enableGitHubAttribution: false, + theme: 'system', + appFontFamily: 'Geist', + editorAutoSave: false, + editorAutoSaveDelayMs: 1000, + editorMinimapEnabled: false, + markdownReviewToolsEnabled: true, + terminalFontSize: 14, + terminalFontFamily: 'monospace', + terminalFontWeight: 400, + terminalLineHeight: 1.2 + } as unknown as GlobalSettings +} + +function makeGitWorktree(overrides: Partial = {}): Worktree { + return { + id: `repo-1::${overrides.path ?? '/repo'}`, + repoId: repo.id, + path: '/repo', + displayName: 'repo', + branch: 'refs/heads/main', + head: 'abc123', + isBare: false, + isMainWorktree: true, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'todo', + ...overrides + } +} + describe('external worktree inbox', () => { it('merges inbox paths without duplicates when paths match after normalization', () => { expect(mergeExternalWorktreeInboxPaths(['/repo/one/'], ['/repo/one', '/repo/two'])).toEqual([ @@ -103,6 +162,25 @@ describe('external worktree inbox', () => { ).toEqual([hidden]) }) + it('offers metadata-free nested Orca workspace worktrees through the inbox', () => { + const settings = makeSettings() + const manual = toDetectedWorktree({ + repo, + settings, + worktree: makeGitWorktree({ + path: '/orca/workspaces/orca/manual-from-git', + displayName: 'manual-from-git', + branch: 'refs/heads/manual-from-git', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(manual.ownership).toBe('external') + expect(manual.visible).toBe(false) + expect(getNewExternalWorktreeInboxWorktrees(detectedResult([manual]), repo)).toEqual([manual]) + }) + it('suppresses non-authoritative detected results', () => { expect( getNewExternalWorktreeInboxWorktrees(detectedResult([detectedWorktree()]), { diff --git a/src/shared/worktree-ownership-worktree-base-path.test.ts b/src/shared/worktree-ownership-worktree-base-path.test.ts index 968b1f3dab2..d6af4dab77d 100644 --- a/src/shared/worktree-ownership-worktree-base-path.test.ts +++ b/src/shared/worktree-ownership-worktree-base-path.test.ts @@ -48,7 +48,7 @@ describe('repo-specific worktree ownership layouts', () => { worktree: makeWorktree('/projects/a/worktrees/repo/feature'), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repoA) }) - ).toBe('orca-managed') + ).toBe('external') expect( classifyWorktreeOwnership({ repo: repoB, @@ -73,7 +73,7 @@ describe('repo-specific worktree ownership layouts', () => { worktree: makeWorktree('C:\\projects\\App\\worktrees\\repo\\Feature'), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('includes relative global layouts for SSH repos without applying absolute desktop paths', () => { diff --git a/src/shared/worktree-ownership.test.ts b/src/shared/worktree-ownership.test.ts index c7c77ea5937..0b41859d28b 100644 --- a/src/shared/worktree-ownership.test.ts +++ b/src/shared/worktree-ownership.test.ts @@ -108,7 +108,7 @@ describe('worktree ownership classification', () => { ).toBe('orca-managed') }) - it('requires the nested repo-specific path shape for path-only ownership', () => { + it('treats nested Orca workspace paths without metadata as external', () => { const repo = makeRepo() const settings = makeSettings() const layouts = buildKnownOrcaWorkspaceLayouts(settings, repo) @@ -119,7 +119,7 @@ describe('worktree ownership classification', () => { worktree: makeWorktree({ path: '/orca/workspaces/app/feature' }), knownOrcaLayouts: layouts }) - ).toBe('orca-managed') + ).toBe('external') expect( classifyWorktreeOwnership({ repo, @@ -130,6 +130,94 @@ describe('worktree ownership classification', () => { ).toBe('external') }) + it('treats explicit Orca creation layout metadata as managed', () => { + const repo = makeRepo() + const settings = makeSettings() + expect( + classifyWorktreeOwnership({ + repo, + settings, + worktree: makeWorktree({ path: '/orca/workspaces/app/feature' }), + meta: makeMeta({ + orcaCreationWorkspaceLayout: { path: '/orca/workspaces', nestWorkspaces: true } + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + ).toBe('orca-managed') + }) + + it('does not treat metadata-free nested workspace paths as Orca-managed for new repos', () => { + const repo = makeRepo({ externalWorktreeVisibility: 'hide' }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + + it('does not treat generic discovery metadata on nested workspace paths as Orca-managed', () => { + const repo = makeRepo({ externalWorktreeVisibility: 'hide' }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + meta: makeMeta({ displayName: 'manual-git-worktree' }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + + it('keeps nested workspace paths visible for legacy repos without explicit visibility', () => { + const repo = makeRepo() + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(true) + }) + + it('hides metadata-free nested workspace paths for legacy repos that hide external worktrees', () => { + const repo = makeRepo({ + externalWorktreeVisibility: 'hide', + externalWorktreeVisibilityLegacy: true + }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + it('treats flat workspace-root descendants as unknown legacy without strong metadata', () => { const repo = makeRepo() const settings = makeSettings({ nestWorkspaces: false }) @@ -172,7 +260,7 @@ describe('worktree ownership classification', () => { worktree: makeWorktree({ path: '/old/workspaces/app/feature' }), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('builds known layouts from large workspace history lists', () => { @@ -214,7 +302,7 @@ describe('worktree ownership classification', () => { }), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('keeps selected linked checkouts visible without trusting Git main-worktree', () => { diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree-ownership.ts index 9140f41795b..94ba1d908ff 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree-ownership.ts @@ -1,5 +1,4 @@ import { - getRuntimePathBasename, isRuntimePathAbsolute, isWindowsAbsolutePathLike, normalizeRuntimePathForComparison, @@ -160,15 +159,13 @@ export function classifyWorktreeOwnership(args: { return 'orca-managed' } - if (matchesStrongOrcaCreatePath(args.worktree.path, args.knownOrcaLayouts, args.repo)) { - return 'orca-managed' - } - if (isUnderFlatOrUntrustedOrcaRoot(args.worktree.path, args.knownOrcaLayouts)) { return 'unknown-legacy' } if (canClassifyAsExternal(args.worktree.path, args.knownOrcaLayouts)) { + // Why: a plain `git worktree add` can target Orca's nested workspace + // folder. Only metadata proves Orca created it. return 'external' } @@ -240,6 +237,7 @@ export function areRuntimePathsEqual(leftPath: string, rightPath: string): boole function hasStrongOrcaMetadata(meta: WorktreeMeta | undefined): boolean { return Boolean( meta?.orcaCreatedAt || + meta?.orcaCreationWorkspaceLayout || meta?.createdAt || meta?.createdWithAgent || meta?.pushTarget || @@ -249,38 +247,6 @@ function hasStrongOrcaMetadata(meta: WorktreeMeta | undefined): boolean { ) } -export function matchesStrongOrcaCreatePath( - worktreePath: string, - knownOrcaLayouts: readonly OrcaWorkspaceLayout[], - repo: Pick -): boolean { - const repoName = getRuntimePathBasename(repo.path).replace(/\.git$/i, '') - if (!repoName) { - return false - } - for (const layout of knownOrcaLayouts) { - if (!layout.nestWorkspaces) { - continue - } - const relative = relativePathInsideRoot(layout.path, worktreePath) - if (relative === null) { - continue - } - const segments = splitNormalizedPath(relative) - const caseInsensitive = - isWindowsAbsolutePathLike(layout.path) || isWindowsAbsolutePathLike(worktreePath) - if ( - segments.length === 2 && - normalizePathSegment(segments[0], caseInsensitive) === - normalizePathSegment(repoName, caseInsensitive) && - segments[1].length > 0 - ) { - return true - } - } - return false -} - function isUnderFlatOrUntrustedOrcaRoot( worktreePath: string, knownOrcaLayouts: OrcaWorkspaceLayout[] @@ -313,12 +279,3 @@ function canClassifyAsExternal( } return true } - -function splitNormalizedPath(value: string): string[] { - return normalizeRuntimePathSeparators(value).split('/').filter(Boolean) -} - -function normalizePathSegment(value: string, caseInsensitive: boolean): string { - const normalized = normalizeRuntimePathSeparators(value) - return caseInsensitive ? normalized.toLowerCase() : normalized -} From fc17c57b6bef355f9860f359c1c3851bb7deff6d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:42:44 -0700 Subject: [PATCH 12/70] Fix Windows SignPath PSGallery preflight (#7135) * Verify Windows app executable signing * Isolate Windows signing verifier tests * Handle direct Windows installer extraction * Fix SignPath PowerShell gallery preflight --------- Co-authored-by: Neil --- .github/workflows/release-cut.yml | 5 +++++ config/scripts/package-electron-runtime-contract.test.mjs | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 957ae1704fa..2822b71e9dc 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -889,6 +889,11 @@ jobs: if: matrix.platform == 'win' shell: pwsh run: | + # Why: Some hosted Windows images start without PSGallery registered. + if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSRepository -Default -InstallationPolicy Trusted + } + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted $trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index d3b3e1226a6..e18765e99d1 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -194,6 +194,10 @@ describe('Electron runtime package contract', () => { expect(installStep.if).toBe("matrix.platform == 'win'") expect(installStep.shell).toBe('pwsh') + expect(installRun).toContain( + 'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))' + ) + expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted') expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted') expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/) expect(installRun).toContain( From 33fda893e968e7fdcf519d86c926ace4ddf897f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:43:30 +0000 Subject: [PATCH 13/70] release: v1.4.118-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7c5842845da..eda77782c2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.118-rc.1", + "version": "1.4.118-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 568a86919563ddf1441ae451d29308f58f400278 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:48:53 +0000 Subject: [PATCH 14/70] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 79f778f0a38..f83dbfcb7ae 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.2m + + downloads: 3.3m @@ -15,7 +15,7 @@ downloads downloads - 3.2m - 3.2m + 3.3m + 3.3m From 186f6c9e3de878b08cc90d998744e91d7f3bb6ff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:11:27 -0700 Subject: [PATCH 15/70] Fix stale terminal frames on worktree return: clear alt screen on snapshot restore + WebGL reveal hardening (#7133) Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 15 +- .../terminal-pane/pty-connection.ts | 7 + .../terminal-visibility-resume.test.ts | 85 ++++++++++ .../terminal-visibility-resume.ts | 5 + .../use-terminal-pane-global-effects.test.ts | 20 ++- .../src/lib/pane-manager/pane-manager.ts | 8 + .../pane-manager/pane-reveal-repaint.test.ts | 147 ++++++++++++++++++ .../lib/pane-manager/pane-reveal-repaint.ts | 39 +++++ .../pane-manager/pane-webgl-renderer.test.ts | 126 +++++++++++++++ .../lib/pane-manager/pane-webgl-renderer.ts | 38 +++-- 10 files changed, 478 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index cd806774737..211e6c1c819 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -7316,7 +7316,7 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('preserves scrollback by skipping the destructive clear when restoring an alternate-screen snapshot', async () => { + it('clears only the alternate screen when restoring an alternate-screen snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { @@ -7365,6 +7365,19 @@ describe('connectPanePty', () => { '\x1b[2J\x1b[3J\x1b[H', expect.any(Function) ) + // Why: the snapshot's ?1049h no-ops on an already-alt pane and serialized + // frames skip blank cells, so the pre-hide frame bleeds through unless the + // alt screen is cleared (without \x1b[3J) before the snapshot paints. + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b[?1049h\x1b[2J\x1b[H', + expect.any(Function) + ) + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + (call) => call[0] + ) + expect(writes.indexOf('\x1b[?1049h\x1b[2J\x1b[H')).toBeLessThan( + writes.indexOf('altscreen-snapshot\r\n') + ) expect(pane.terminal.write).toHaveBeenCalledWith('altscreen-snapshot\r\n', expect.any(Function)) disposable.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 85c50f6fe8a..01f0132c3fc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -4393,6 +4393,13 @@ export function connectPanePty( // clearing on restore loses scroll-up after a hidden->visible return. // Mirrors the attach-time guard in pty-transport.ts. writeReplayData('\x1b[2J\x1b[3J\x1b[H') + } else { + // Why: the snapshot's own ?1049h is a no-op when the pane is already on + // the alternate screen, and the serialized frame skips blank cells — so + // without clearing the alt screen the pre-hide frame bleeds through + // every cell the final frame leaves blank. \x1b[2J on the alt buffer + // does not touch the normal buffer's scrollback the TUI returns to. + writeReplayData('\x1b[?1049h\x1b[2J\x1b[H') } writeReplayData(snapshot.data) writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts new file mode 100644 index 00000000000..d4b9762163a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { + recoverVisibleTerminalWindowWake, + resumeTerminalVisibility +} from './terminal-visibility-resume' + +vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn() +})) +vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ + flushTerminalOutput: vi.fn(), + requestTerminalBacklogRecovery: vi.fn() +})) +vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ + enforceTerminalCurrentScrollIntent: vi.fn() +})) +vi.mock('./pane-helpers', () => ({ + fitAndFocusPanes: vi.fn(), + fitPanes: vi.fn(), + focusActivePane: vi.fn() +})) +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery: vi.fn() +})) + +type FakeManager = { + getPanes: ReturnType + resumeRendering: ReturnType + scheduleRevealRepaint: ReturnType +} + +function createManager(order: string[] = []): FakeManager { + return { + getPanes: vi.fn(() => []), + resumeRendering: vi.fn(() => order.push('resume-rendering')), + scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')) + } +} + +function resumeArgs(manager: FakeManager, shouldUseLightTabResume: boolean) { + return { + manager: manager as never as PaneManager, + isActive: true, + wasVisible: false, + shouldUseLightTabResume, + captureViewportPositions: vi.fn(() => new Map()), + withSuppressedScrollTracking: (callback: () => void) => callback() + } +} + +describe('resumeTerminalVisibility reveal repaint', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('schedules a pane-scoped repaint on a light tab reveal', () => { + // The light path is the "click the tab that was not open" gesture: it has + // no rendering resume or fit, so without this repaint a hidden-while- + // working pane keeps compositing pre-hide pixels. + const manager = createManager() + resumeTerminalVisibility(resumeArgs(manager, true)) + + expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) + expect(manager.resumeRendering).not.toHaveBeenCalled() + }) + + it('schedules the repaint after rendering resumes on a heavy reveal', () => { + const order: string[] = [] + const manager = createManager(order) + resumeTerminalVisibility(resumeArgs(manager, false)) + + expect(order).toEqual(['resume-rendering', 'reveal-repaint']) + }) + + it('schedules the repaint on window-wake recovery', () => { + const manager = createManager() + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: false + }) + + expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 408f8afe952..fd9ff1260f4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -75,6 +75,10 @@ export function resumeTerminalVisibility({ // terminals; refresh after reset so rebuilt atlases repaint from xterm. resetAndRefreshAllTerminalWebglAtlases() } + // Why: the synchronous recovery above can fire before the revealed pane is + // attached and laid out, where the WebGL renderer drops redraw requests + // without retry. Follow up with a settled-frame, pane-scoped repaint. + manager.scheduleRevealRepaint() }) } @@ -133,6 +137,7 @@ export function recoverVisibleTerminalWindowWake({ } enforceTerminalViewportIntents(manager) resetAndRefreshAllTerminalWebglAtlases() + manager.scheduleRevealRepaint() } function requestLightTabBacklogRecovery(manager: PaneManager): void { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 61dc3a1533d..0ad8f80d141 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -130,6 +130,7 @@ function useMountForFileDrop( getPanes: ReturnType resumeRendering: ReturnType resetWebglTextureAtlases: ReturnType + scheduleRevealRepaint: ReturnType suspendRendering: ReturnType getActivePane: ReturnType } @@ -146,6 +147,7 @@ function useMountForFileDrop( getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -222,6 +224,7 @@ describe('useTerminalPaneGlobalEffects', () => { ]), resumeRendering: vi.fn(() => order.push('resume')), resetWebglTextureAtlases: vi.fn(() => order.push('reset-atlas')), + scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')), refreshAllPanes: vi.fn(() => order.push('refresh')), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -280,7 +283,8 @@ describe('useTerminalPaneGlobalEffects', () => { 'intent:terminal-a', 'intent:terminal-b', 'reset-atlas', - 'refresh' + 'refresh', + 'reveal-repaint' ]) expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled() expect(mocks.flushTerminalOutput).toHaveBeenNthCalledWith(1, terminalA, { @@ -308,6 +312,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -387,6 +392,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -448,6 +454,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -501,6 +508,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -578,6 +586,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal: { name: 'terminal-a' } }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => ({ id: 1, terminal: { name: 'terminal-a' } })) } @@ -609,6 +618,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal: terminalA }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null), @@ -667,6 +677,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -711,6 +722,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => ({ id: 1, terminal })) @@ -775,6 +787,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -829,6 +842,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -874,6 +888,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [pane]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => pane) } @@ -929,6 +944,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [pane]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => pane) } @@ -1044,6 +1060,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null) @@ -1077,6 +1094,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null) diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 4824af7cf57..61ff5a7d42d 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -38,6 +38,7 @@ import { } from './pane-rendering-control' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry' +import { schedulePaneRevealRepaint } from './pane-reveal-repaint' import { PaneIdentityRegistry } from './pane-identity-registry' import { closeManagedPane, splitManagedPane } from './pane-split-close' import { FIRST_PANE_ID } from '../../../../shared/pane-key' @@ -290,6 +291,13 @@ export class PaneManager { resetPaneWebglTextureAtlases(this.panes.values()) } + scheduleRevealRepaint(): void { + // Why: the settled-frame callback can fire after destroy(); repainting + // disposed panes could throw in attach and latch the global WebGL + // attach backoff, downgrading unrelated new panes to the DOM renderer. + schedulePaneRevealRepaint(() => (this.destroyed ? [] : this.panes.values())) + } + suspendRendering(): void { this.renderingSuspended = true suspendPaneRendering(this.panes.values()) diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts new file mode 100644 index 00000000000..564a57e9875 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import { schedulePaneRevealRepaint } from './pane-reveal-repaint' +import { resetTerminalWebglSuggestion } from './pane-webgl-renderer' + +type FakeWebglAddon = { clearTextureAtlas: ReturnType } + +function createPane(options: { webglAddon?: FakeWebglAddon | null } = {}): ManagedPaneInternal { + const leafId = '33333333-3333-4333-8333-333333333333' as never + return { + id: 1, + leafId, + stablePaneId: leafId, + terminal: { + cols: 80, + rows: 24, + refresh: vi.fn(), + loadAddon: vi.fn() + } as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'on', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: (options.webglAddon ?? null) as never, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + pendingWebglRefreshRafId: null, + fitAddon: { + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })), + fit: vi.fn() + } as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +describe('schedulePaneRevealRepaint', () => { + let rafQueue: FrameRequestCallback[] + + function flushFrame(): void { + const queue = rafQueue + rafQueue = [] + for (const callback of queue) { + callback(16) + } + } + + beforeEach(() => { + resetTerminalWebglSuggestion() + rafQueue = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + rafQueue.push(callback) + return rafQueue.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('repaints only after the post-reveal frame has settled', () => { + const webglAddon = { clearTextureAtlas: vi.fn() } + const pane = createPane({ webglAddon }) + schedulePaneRevealRepaint(() => [pane]) + + // First frame: reveal layout may still be in flight; redraw requests fired + // here can be dropped by the renderer without retry. + flushFrame() + expect(webglAddon.clearTextureAtlas).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + + flushFrame() + expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1) + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('reattaches a missing WebGL addon before repainting', () => { + const pane = createPane() + schedulePaneRevealRepaint(() => [pane]) + + flushFrame() + flushFrame() + + expect(pane.webglAddon).not.toBeNull() + expect(pane.terminal.refresh).toHaveBeenCalled() + }) + + it('resolves the pane list at repaint time, not at scheduling time', () => { + const stalePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } }) + const livePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } }) + const panes = [stalePane] + schedulePaneRevealRepaint(() => panes) + panes.splice(0, panes.length, livePane) + + flushFrame() + flushFrame() + + expect( + (stalePane.webglAddon as never as FakeWebglAddon).clearTextureAtlas + ).not.toHaveBeenCalled() + expect( + (livePane.webglAddon as never as FakeWebglAddon).clearTextureAtlas + ).toHaveBeenCalledTimes(1) + }) + + it('keeps repainting remaining panes when one pane throws', () => { + const explosivePane = { + get gpuRenderingEnabled(): boolean { + throw new Error('pane torn down mid-frame') + } + } as never as ManagedPaneInternal + const webglAddon = { clearTextureAtlas: vi.fn() } + const livePane = createPane({ webglAddon }) + schedulePaneRevealRepaint(() => [explosivePane, livePane]) + + flushFrame() + flushFrame() + + expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1) + expect(livePane.terminal.refresh).toHaveBeenCalled() + }) + + it('falls back to a timeout when animation frames are unavailable', () => { + vi.useFakeTimers() + vi.stubGlobal('requestAnimationFrame', undefined) + const webglAddon = { clearTextureAtlas: vi.fn() } + const pane = createPane({ webglAddon }) + + schedulePaneRevealRepaint(() => [pane]) + vi.runAllTimers() + + expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts new file mode 100644 index 00000000000..48df07b9b0c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts @@ -0,0 +1,39 @@ +import type { ManagedPaneInternal } from './pane-manager-types' +import { reattachWebglIfNeeded } from './pane-webgl-reattach' +import { resetWebglTextureAtlas } from './pane-webgl-renderer' + +function scheduleSettledFrame(callback: () => void): void { + if (typeof globalThis.requestAnimationFrame !== 'function') { + globalThis.setTimeout(callback, 0) + return + } + // Why: the first frame after a reveal can still be laying out the tab + // overlay; the WebGL renderer silently drops redraw requests until the pane + // is attached and measured, so repaint on the frame after layout settles. + globalThis.requestAnimationFrame(() => { + globalThis.requestAnimationFrame(callback) + }) +} + +/** + * Repaints a revealed tab's panes from their xterm buffers. + * + * Why: while a pane is hidden, parsed output can update the WebGL renderer's + * per-cell model without ever presenting a frame. At reveal the model diff + * reports those cells unchanged, so plain refreshes skip them and the canvas + * keeps compositing pre-hide pixels until a selection or resize rebuilds the + * model. Clearing the model per pane — after (re)attach, once layout has + * settled — forces a full rebuild from the buffer without any PTY resize. + */ +export function schedulePaneRevealRepaint(getPanes: () => Iterable): void { + scheduleSettledFrame(() => { + for (const pane of getPanes()) { + try { + reattachWebglIfNeeded(pane) + resetWebglTextureAtlas(pane) + } catch { + /* ignore — one pane's failure must not block repaint of the rest */ + } + } + }) +} diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts new file mode 100644 index 00000000000..26919ed4d2d --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebglAddon } from '@xterm/addon-webgl' +import type { ManagedPaneInternal } from './pane-manager-types' +import { + attachWebgl, + resetTerminalWebglSuggestion, + resetWebglTextureAtlas +} from './pane-webgl-renderer' + +function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneInternal { + const leafId = '22222222-2222-4222-8222-222222222222' as never + return { + id: 1, + leafId, + stablePaneId: leafId, + terminal: { + cols: 80, + rows: 24, + refresh: vi.fn(), + loadAddon: vi.fn(options.loadAddon) + } as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'on', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: null, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + pendingWebglRefreshRafId: null, + fitAddon: { + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })), + fit: vi.fn() + } as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +describe('terminal WebGL addon lifecycle', () => { + beforeEach(() => { + resetTerminalWebglSuggestion() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('disposes a live addon when attach bails instead of orphaning it', () => { + const pane = createPane() + attachWebgl(pane) + const liveAddon = pane.webglAddon + expect(liveAddon).not.toBeNull() + const disposeSpy = vi.spyOn(liveAddon as WebglAddon, 'dispose') + + // An undisposed addon here kept painting stale frames while atlas resets, + // reattach checks, and diagnostics all treated the pane as DOM-rendered. + pane.webglAttachmentDeferred = true + attachWebgl(pane) + + expect(disposeSpy).toHaveBeenCalledTimes(1) + expect(pane.webglAddon).toBeNull() + }) + + it('disposes the previous addon before attaching a replacement', () => { + const pane = createPane() + attachWebgl(pane) + const firstAddon = pane.webglAddon + expect(firstAddon).not.toBeNull() + const disposeSpy = vi.spyOn(firstAddon as WebglAddon, 'dispose') + + attachWebgl(pane) + + expect(disposeSpy).toHaveBeenCalledTimes(1) + expect(pane.webglAddon).not.toBeNull() + expect(pane.webglAddon).not.toBe(firstAddon) + }) + + it('disposes the constructed addon when loading it fails', () => { + const disposeSpy = vi.spyOn(WebglAddon.prototype, 'dispose') + const pane = createPane({ + loadAddon: () => { + throw new Error('WebGL2 not supported null') + } + }) + + attachWebgl(pane) + + expect(pane.webglAddon).toBeNull() + expect(disposeSpy).toHaveBeenCalledTimes(1) + }) + + it('still refreshes the terminal when resetting a pane without a WebGL addon', () => { + const pane = createPane() + expect(pane.webglAddon).toBeNull() + + resetWebglTextureAtlas(pane) + + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('skips the reset while WebGL is latched off after a context loss', () => { + const pane = createPane() + pane.webglDisabledAfterContextLoss = true + + resetWebglTextureAtlas(pane) + + expect(pane.terminal.refresh).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index b84ba23590b..cca7cc9dd17 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -47,10 +47,13 @@ function refreshTerminalAfterWebglAttach(pane: ManagedPaneInternal): void { } export function cancelPendingWebglRefresh(pane: ManagedPaneInternal): void { - if (pane.pendingWebglRefreshRafId != null) { - cancelAnimationFrame(pane.pendingWebglRefreshRafId) - pane.pendingWebglRefreshRafId = null + if (pane.pendingWebglRefreshRafId == null) { + return } + if (typeof globalThis.cancelAnimationFrame === 'function') { + globalThis.cancelAnimationFrame(pane.pendingWebglRefreshRafId) + } + pane.pendingWebglRefreshRafId = null } export function disposeWebgl( @@ -87,14 +90,16 @@ export function markComplexScriptOutput(pane: ManagedPaneInternal): void { } export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { - if (!pane.webglAddon || pane.webglDisabledAfterContextLoss) { + if (pane.webglDisabledAfterContextLoss) { return } try { // Why: rapid TUI redraws can corrupt xterm's WebGL glyph atlas without a // context-loss event. Clearing the atlas preserves GPU rendering and forces // a fresh paint when the pane becomes visible/focused again. - pane.webglAddon.clearTextureAtlas() + pane.webglAddon?.clearTextureAtlas() + // Why: refresh even without a WebGL addon so recovery never silently + // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. pane.terminal.refresh(0, pane.terminal.rows - 1) } catch { /* ignore — pane may have been disposed in the meantime */ @@ -110,12 +115,20 @@ export function attachWebgl(pane: ManagedPaneInternal): void { pane.webglDisabledAfterContextLoss || webglAttachFailedSinceRecovery ) { - pane.webglAddon = null + // Why: nulling the reference here used to leak a still-loaded addon that + // kept painting stale frames while every recovery path (atlas reset, + // reattach, diagnostics) treated the pane as DOM-rendered. Dispose so the + // pane genuinely falls back to the DOM renderer. + disposeWebgl(pane, { refreshDimensions: true }) return } + // Single-addon invariant: never stack a second addon on a live one. + disposeWebgl(pane) + let webglAddon: WebglAddon | null = null try { - const webglAddon = new WebglAddon() - webglAddon.onContextLoss(() => { + webglAddon = new WebglAddon() + const addon = webglAddon + addon.onContextLoss(() => { console.warn( '[terminal] WebGL context lost for pane', pane.id, @@ -128,8 +141,8 @@ export function attachWebgl(pane: ManagedPaneInternal): void { pane.webglDisabledAfterContextLoss = true disposeWebgl(pane, { refreshDimensions: true }) }) - pane.terminal.loadAddon(webglAddon) - pane.webglAddon = webglAddon + pane.terminal.loadAddon(addon) + pane.webglAddon = addon refreshTerminalAfterWebglAttach(pane) } catch (err) { if (pane.terminalGpuAcceleration === 'auto') { @@ -140,6 +153,11 @@ export function attachWebgl(pane: ManagedPaneInternal): void { webglAttachFailedSinceRecovery = true // WebGL not available — default DOM renderer is fine, but log it for debugging console.warn('[terminal] WebGL unavailable for pane', pane.id, '— using DOM renderer:', err) + try { + webglAddon?.dispose() + } catch { + /* ignore — a half-constructed addon may throw on dispose */ + } pane.webglAddon = null } } From d2d3e90c960979dd9380668d6c4d012e62e3684f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:11:58 -0700 Subject: [PATCH 16/70] Fix Windows inner signature PowerShell path (#7136) Co-authored-by: Neil --- config/scripts/verify-windows-inner-signature.mjs | 14 ++++++++++---- .../verify-windows-inner-signature.test.mjs | 11 +++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/config/scripts/verify-windows-inner-signature.mjs b/config/scripts/verify-windows-inner-signature.mjs index 0f3520c1dee..e0c5e303dbc 100644 --- a/config/scripts/verify-windows-inner-signature.mjs +++ b/config/scripts/verify-windows-inner-signature.mjs @@ -7,7 +7,7 @@ export const DEFAULT_EXPECTED_SIGNER = const POWERSHELL_SIGNATURE_SCRIPT = String.raw` $ErrorActionPreference = 'Stop' -$signature = Get-AuthenticodeSignature -FilePath $args[0] +$signature = Get-AuthenticodeSignature -FilePath $env:ORCA_WINDOWS_INNER_EXECUTABLE $certificate = $signature.SignerCertificate [pscustomobject]@{ status = $signature.Status.ToString() @@ -130,6 +130,7 @@ export function validateExecutablePath(executablePath) { } export function getPowerShellSignatureJson(executablePath, spawnSyncImpl = spawnSync) { + // Why: pwsh -Command does not reliably expose trailing process args to string commands. const result = spawnSyncImpl( 'pwsh', [ @@ -139,10 +140,15 @@ export function getPowerShellSignatureJson(executablePath, spawnSyncImpl = spawn '-ExecutionPolicy', 'Bypass', '-Command', - POWERSHELL_SIGNATURE_SCRIPT, - executablePath + POWERSHELL_SIGNATURE_SCRIPT ], - { encoding: 'utf8' } + { + encoding: 'utf8', + env: { + ...process.env, + ORCA_WINDOWS_INNER_EXECUTABLE: executablePath + } + } ) if (result.error) { diff --git a/config/scripts/verify-windows-inner-signature.test.mjs b/config/scripts/verify-windows-inner-signature.test.mjs index aa0e1e7ae12..d833fb64774 100644 --- a/config/scripts/verify-windows-inner-signature.test.mjs +++ b/config/scripts/verify-windows-inner-signature.test.mjs @@ -172,8 +172,15 @@ describe('verify-windows-inner-signature', () => { ) expect(calls[0].command).toBe('pwsh') expect(calls[0].args).toContain('-Command') - expect(calls[0].args.at(-1)).toBe('C:\\Path With Spaces\\Orca.exe') - expect(calls[0].options).toEqual({ encoding: 'utf8' }) + expect(calls[0].args.at(-1)).not.toBe('C:\\Path With Spaces\\Orca.exe') + expect(calls[0].options).toEqual( + expect.objectContaining({ + encoding: 'utf8', + env: expect.objectContaining({ + ORCA_WINDOWS_INNER_EXECUTABLE: 'C:\\Path With Spaces\\Orca.exe' + }) + }) + ) expect(() => getPowerShellSignatureJson('Orca.exe', () => ({ status: 0, stdout: '{}', stderr: 'warning' })) From 6bca68ae09b75147e657ad5c894e4c3bbec3c4f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:13:04 +0000 Subject: [PATCH 17/70] release: v1.4.118-rc.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eda77782c2b..73cef4c1637 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.118-rc.2", + "version": "1.4.118-rc.3", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 49a8e805265f74e82318148740514e8e8067f525 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:34:35 -0700 Subject: [PATCH 18/70] Temporarily bypass Windows inner signature gate (#7137) Co-authored-by: Neil --- .github/workflows/release-cut.yml | 45 ------------------- ...package-electron-runtime-contract.test.mjs | 36 ++------------- 2 files changed, 3 insertions(+), 78 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 2822b71e9dc..a7b8fe1814b 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1067,51 +1067,6 @@ jobs: } $signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint - - name: Verify signed Windows inner executable - if: matrix.platform == 'win' - shell: pwsh - env: - ORCA_WINDOWS_EXPECTED_SIGNERS: CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US;CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, ST=Delaware, C=US - run: | - # Why: SignPath must recursively sign nested PE files in the dashboard - # artifact configuration; this gate keeps unsigned app payloads out of releases. - $installer = Join-Path -Path (Get-Location) -ChildPath 'dist/orca-windows-setup.exe' - $setupExtractDir = Join-Path -Path $env:RUNNER_TEMP -ChildPath 'orca-signed-setup' - $appExtractDir = Join-Path -Path $env:RUNNER_TEMP -ChildPath 'orca-signed-app' - - Remove-Item -LiteralPath $setupExtractDir, $appExtractDir -Recurse -Force -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Path $setupExtractDir, $appExtractDir -Force | Out-Null - - & 7z x -y "-o$setupExtractDir" $installer - if ($LASTEXITCODE -ne 0) { - throw "Failed to extract signed Windows installer with 7z. Exit code: $LASTEXITCODE" - } - - # Why: 7-Zip can expose electron-builder NSIS payloads either as an - # app-root tree directly or as a nested app-*.7z archive. - $innerExecutables = @(Get-ChildItem -LiteralPath $setupExtractDir -File -Filter 'Orca.exe') - if ($innerExecutables.Count -eq 0) { - $appArchives = @(Get-ChildItem -LiteralPath $setupExtractDir -Recurse -File -Filter 'app-*.7z') - if ($appArchives.Count -ne 1) { - $matches = ($appArchives | ForEach-Object { $_.FullName }) -join [Environment]::NewLine - throw "Expected app-root Orca.exe or exactly one app-*.7z payload in signed Windows installer; found $($appArchives.Count) app archive(s).$([Environment]::NewLine)$matches" - } - - & 7z x -y "-o$appExtractDir" $($appArchives[0].FullName) - if ($LASTEXITCODE -ne 0) { - throw "Failed to extract signed Windows app payload with 7z. Exit code: $LASTEXITCODE" - } - - $innerExecutables = @(Get-ChildItem -LiteralPath $appExtractDir -File -Filter 'Orca.exe') - } - - if ($innerExecutables.Count -ne 1) { - $matches = ($innerExecutables | ForEach-Object { $_.FullName }) -join [Environment]::NewLine - throw "Expected exactly one app-root Orca.exe in signed Windows app payload; found $($innerExecutables.Count).$([Environment]::NewLine)$matches" - } - - node config/scripts/verify-windows-inner-signature.mjs $($innerExecutables[0].FullName) - - name: Publish signed Windows release artifacts if: matrix.platform == 'win' uses: nick-fields/retry@v4 diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index e18765e99d1..c106b166feb 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -221,7 +221,7 @@ describe('Electron runtime package contract', () => { expect(installRun).not.toMatch(/throw\s+\$_/) }) - it('verifies the SignPath-signed Windows inner executable before publishing', () => { + it('temporarily allows publishing Windows after verifying the signed installer only', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), 'utf8' @@ -234,38 +234,8 @@ describe('Electron runtime package contract', () => { const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts') expect(outerVerifyIndex).toBeGreaterThan(-1) - expect(innerVerifyIndex).toBe(outerVerifyIndex + 1) - expect(innerVerifyIndex).toBeLessThan(publishIndex) - - const innerVerifyStep = steps[innerVerifyIndex] - const innerVerifyRun = innerVerifyStep.run - - expect(innerVerifyStep.if).toBe("matrix.platform == 'win'") - expect(innerVerifyStep.shell).toBe('pwsh') - expect(innerVerifyStep.env.ORCA_WINDOWS_EXPECTED_SIGNERS).toBe( - 'CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US;CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, ST=Delaware, C=US' - ) - expect(innerVerifyRun).toContain('& 7z x -y "-o$setupExtractDir" $installer') - expect(innerVerifyRun).toContain( - "$innerExecutables = @(Get-ChildItem -LiteralPath $setupExtractDir -File -Filter 'Orca.exe')" - ) - expect(innerVerifyRun).toContain('if ($innerExecutables.Count -eq 0)') - expect(innerVerifyRun).toContain( - "$appArchives = @(Get-ChildItem -LiteralPath $setupExtractDir -Recurse -File -Filter 'app-*.7z')" - ) - expect(innerVerifyRun).toContain('if ($appArchives.Count -ne 1)') - expect(innerVerifyRun).toContain('Expected app-root Orca.exe or exactly one app-*.7z') - expect(innerVerifyRun).toContain('& 7z x -y "-o$appExtractDir" $($appArchives[0].FullName)') - expect(innerVerifyRun).toContain( - "$innerExecutables = @(Get-ChildItem -LiteralPath $appExtractDir -File -Filter 'Orca.exe')" - ) - expect(innerVerifyRun.indexOf("-Filter 'Orca.exe'")).toBeLessThan( - innerVerifyRun.indexOf("-Filter 'app-*.7z'") - ) - expect(innerVerifyRun).toContain('if ($innerExecutables.Count -ne 1)') - expect(innerVerifyRun).toContain( - 'node config/scripts/verify-windows-inner-signature.mjs $($innerExecutables[0].FullName)' - ) + expect(innerVerifyIndex).toBe(-1) + expect(publishIndex).toBe(outerVerifyIndex + 1) }) it('publishes both Linux release matrix entries', () => { From a81629e8620c8090e93be37326f04bab8ef6f6eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:35:52 +0000 Subject: [PATCH 19/70] release: v1.4.118-rc.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 73cef4c1637..626d6642a6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.118-rc.3", + "version": "1.4.118-rc.5", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 98a78f1e0c4d40917ad3b5c7dee3e67ff2354e0f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:12:17 +0000 Subject: [PATCH 20/70] release: v1.4.118 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 626d6642a6a..484c9df78fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.118-rc.5", + "version": "1.4.118", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From e6ca3087c58533a2b5de4fae1f0106844399d2ac Mon Sep 17 00:00:00 2001 From: Brandon Barker Date: Thu, 2 Jul 2026 16:55:36 -0400 Subject: [PATCH 21/70] perf(ssh): resolve node path concurrently with remote install state (#6952) Run independent SSH relay bootstrap probes concurrently when the connection can safely support overlapping execs. Preserve the old sequential path for system SSH without reusable ControlMaster and for remotes that reject concurrent session channels. --- src/main/ssh/ssh-connection-utils.ts | 7 + src/main/ssh/ssh-connection.test.ts | 45 ++- src/main/ssh/ssh-connection.ts | 48 +++- .../ssh-relay-cross-version-isolation.test.ts | 1 + src/main/ssh/ssh-relay-deploy-helpers.test.ts | 49 ++++ src/main/ssh/ssh-relay-deploy-helpers.ts | 15 +- src/main/ssh/ssh-relay-deploy.test.ts | 256 ++++++++++++++++++ src/main/ssh/ssh-relay-deploy.ts | 145 ++++++++-- .../ssh/ssh-relay-native-deps-install.test.ts | 1 + .../ssh/ssh-relay-versioned-install.test.ts | 20 ++ src/main/ssh/ssh-relay-versioned-install.ts | 25 +- .../ssh/ssh-remote-node-resolution.test.ts | 61 +++++ src/main/ssh/ssh-remote-node-resolution.ts | 73 +++-- src/main/ssh/ssh-session-limit-error.test.ts | 57 ++++ src/main/ssh/ssh-session-limit-error.ts | 19 ++ 15 files changed, 773 insertions(+), 49 deletions(-) create mode 100644 src/main/ssh/ssh-session-limit-error.test.ts create mode 100644 src/main/ssh/ssh-session-limit-error.ts diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index aea238ef234..cc8269c1b48 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -100,6 +100,13 @@ export function wrapRemoteCommandForPosixShell(command: string): string { export type SshExecOptions = { wrapCommand?: boolean + signal?: AbortSignal +} + +export function createSshOperationAbortError(): Error & { name: string } { + const error = new Error('SSH operation was cancelled') as Error & { name: string } + error.name = 'AbortError' + return error } function cmdEscape(s: string): string { diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index ade239d56f6..f40d27077a8 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -302,6 +302,14 @@ describe('SshConnection', () => { expect(clientInstances[0].setNoDelay).toHaveBeenCalledWith(true) }) + it('allows concurrent exec commands for ssh2 transport', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + + expect(conn.usesSystemSshTransport()).toBe(false) + expect(conn.canRunConcurrentExecCommands()).toBe(true) + }) + it('removes startup listeners after ssh2 connect succeeds', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) @@ -762,8 +770,36 @@ describe('SshConnection', () => { ) }) + it('allows concurrent exec commands for system SSH with an Orca ControlMaster socket', async () => { + getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/live-socket') + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + await conn.connect() + + expect(conn.usesSystemSshTransport()).toBe(true) + expect(conn.canRunConcurrentExecCommands()).toBe(true) + }) + + it('keeps concurrent exec commands disabled for system SSH without a reusable socket', async () => { + getOrcaControlSocketPathMock.mockReturnValue(null) + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection( + createTarget({ configHost: 'fdpass-host', systemSshConnectionReuse: false }), + createCallbacks() + ) + + await conn.connect() + + expect(conn.usesSystemSshTransport()).toBe(true) + expect(conn.canRunConcurrentExecCommands()).toBe(false) + }) + it('retries a failed system SSH probe without ControlMaster and disables mux for the session', async () => { - getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/stale-socket') + getOrcaControlSocketPathMock.mockImplementation( + (_target: SshTarget, options?: { disableControlMaster?: boolean }) => + options?.disableControlMaster ? null : '/tmp/orca-ssh-501/stale-socket' + ) spawnSystemSshCommandMock .mockImplementationOnce(() => createFailingSystemCommandChannel(255, 'mux client failed')) .mockImplementation(() => createSystemCommandChannel()) @@ -812,6 +848,7 @@ describe('SshConnection', () => { resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) }) ) + expect(conn.canRunConcurrentExecCommands()).toBe(false) }) it('uses system SSH transport for ProxyCommand targets before ssh2 auth', async () => { @@ -952,7 +989,10 @@ describe('SshConnection', () => { }) it('retries direct system SSH connections without ControlMaster after mux startup failure', async () => { - getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/stale-socket') + getOrcaControlSocketPathMock.mockImplementation( + (_target: SshTarget, options?: { disableControlMaster?: boolean }) => + options?.disableControlMaster ? null : '/tmp/orca-ssh-501/stale-socket' + ) spawnSystemSshMock .mockReturnValueOnce(createFailingSystemSshProcess(255)) .mockImplementation(() => createSystemSshProcess()) @@ -977,6 +1017,7 @@ describe('SshConnection', () => { resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) } ) + expect(conn.canRunConcurrentExecCommands()).toBe(false) }) it('kills delayed direct system SSH startup on disconnect and ignores late stdout', async () => { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index bdc7543c5fc..528f03c4c58 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -30,6 +30,7 @@ import { resolveEffectiveProxy, spawnProxyCommand, wrapRemoteCommandForPosixShell, + createSshOperationAbortError, type SshExecOptions, type SshConnectionCallbacks } from './ssh-connection-utils' @@ -85,6 +86,16 @@ export class SshConnection { usesSystemSshTransport(): boolean { return this.useSystemSshTransport } + canRunConcurrentExecCommands(): boolean { + if (!this.useSystemSshTransport) { + return true + } + return ( + getOrcaControlSocketPath(this.target, { + ...this.getSystemSshBuildArgsOptions() + }) !== null + ) + } getTarget(): SshTarget { return { ...this.target } } @@ -107,6 +118,9 @@ export class SshConnection { } async exec(cmd: string, options?: SshExecOptions): Promise { + if (options?.signal?.aborted) { + throw createSshOperationAbortError() + } if (this.useSystemSshTransport) { if (this.disposed || this.state.status !== 'connected') { throw new Error('Not connected') @@ -121,7 +135,8 @@ export class SshConnection { return this.waitForSshCallback( 'SSH exec channel timed out', (callback) => client.exec(remoteCommand, callback), - (channel) => channel.close() + (channel) => channel.close(), + options?.signal ) } @@ -143,12 +158,26 @@ export class SshConnection { private waitForSshCallback( timeoutMessage: string, register: (callback: (error: Error | undefined, value: T) => void) => void, - cleanupLateValue?: (value: T) => void + cleanupLateValue?: (value: T) => void, + signal?: AbortSignal ): Promise { return new Promise((resolve, reject) => { let settled = false + const cleanup = (): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + } + const onAbort = (): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(createSshOperationAbortError()) + } const timer = setTimeout(() => { settled = true + signal?.removeEventListener('abort', onAbort) reject(new Error(timeoutMessage)) }, CONNECT_TIMEOUT_MS) const finish = (error: Error | undefined, value?: T): void => { @@ -166,13 +195,18 @@ export class SshConnection { return } settled = true - clearTimeout(timer) + cleanup() if (error) { reject(error) return } resolve(value as T) } + if (signal?.aborted) { + onAbort() + return + } + signal?.addEventListener('abort', onAbort, { once: true }) try { // Why: higher-level channel timers start only after ssh2 invokes its @@ -686,6 +720,9 @@ export class SshConnection { } private spawnTrackedSystemSshCommand(command: string, options?: SshExecOptions): ClientChannel { + if (options?.signal?.aborted) { + throw createSshOperationAbortError() + } const buildArgsOptions = this.getSystemSshBuildArgsOptions() const commandOptions = options === undefined && Object.keys(buildArgsOptions).length === 0 @@ -696,9 +733,14 @@ export class SshConnection { ? spawnSystemSshCommand(this.target, command) : spawnSystemSshCommand(this.target, command, commandOptions) this.systemCommandChannels.add(channel) + const onAbort = (): void => { + channel.close() + } const cleanup = (): void => { + options?.signal?.removeEventListener('abort', onAbort) this.systemCommandChannels.delete(channel) } + options?.signal?.addEventListener('abort', onAbort, { once: true }) channel.once('close', cleanup) channel.once('error', cleanup) return channel diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index e1f93d8ec6d..c426e616fed 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -50,6 +50,7 @@ import type { SshConnection } from './ssh-connection' function makeMockConnection(): SshConnection { return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), exec: vi.fn().mockResolvedValue({ on: vi.fn(), stderr: { on: vi.fn() }, diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index 4b4a3783ca8..8bd40901dae 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -217,6 +217,55 @@ describe('execCommand', () => { } }) + it('closes and rejects with AbortError when a command is aborted', async () => { + const channel = createMockChannel() + const controller = new AbortController() + const conn = { + exec: vi.fn().mockResolvedValue(channel) + } + const commandPromise = execCommand(conn as never, 'sleep 60', { + signal: controller.signal + }) + + await Promise.resolve() + controller.abort() + + await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' }) + expect(channel.close).toHaveBeenCalledOnce() + expect(channel.listenerCount('error')).toBe(0) + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + }) + + it('handles aborts that happen while the SSH exec channel is still opening', async () => { + const channel = createMockChannel() + const controller = new AbortController() + let resolveExec: (channel: ClientChannel) => void = () => {} + const conn = { + exec: vi.fn().mockReturnValue( + new Promise((resolve) => { + resolveExec = resolve + }) + ) + } + + const commandPromise = execCommand(conn as never, 'sleep 60', { + signal: controller.signal + }) + controller.abort() + resolveExec(channel) + + await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' }) + expect(channel.close).toHaveBeenCalledOnce() + expect(channel.listenerCount('error')).toBe(0) + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + }) + it('uses custom command timeouts without forwarding them to SSH exec', async () => { vi.useFakeTimers() try { diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index ecc7f27677c..1473e3233cd 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -1,6 +1,6 @@ import type { ClientChannel } from 'ssh2' import type { SshConnection } from './ssh-connection' -import type { SshExecOptions } from './ssh-connection-utils' +import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils' import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import { buildRelayVersionMismatchError } from './ssh-relay-handshake-mismatch' @@ -247,6 +247,10 @@ export async function execCommand( options?: ExecCommandOptions ): Promise { const { timeoutMs = EXEC_TIMEOUT_MS, ...execOptions } = options ?? {} + const signal = options?.signal + if (signal?.aborted) { + throw createSshOperationAbortError() + } const channel = await conn.exec(command, execOptions) return new Promise((resolve, reject) => { let stdout = '' @@ -255,6 +259,7 @@ export async function execCommand( const cleanup = (): void => { clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) channel.off('error', fail) channel.stderr.off('error', fail) channel.off('data', onStdoutData) @@ -272,6 +277,10 @@ export async function execCommand( const fail = (err: Error): void => { settle(reject, err) } + const onAbort = (): void => { + channel.close() + settle(reject, createSshOperationAbortError()) + } const onStdoutData = (data: Buffer): void => { stdout += data.toString('utf-8') } @@ -293,10 +302,14 @@ export async function execCommand( // Why: remote reboot tears down exec channels with stream errors. Without // scoped listeners, Node treats those as uncaught exceptions. + signal?.addEventListener('abort', onAbort, { once: true }) channel.on('error', fail) channel.stderr.on('error', fail) channel.on('data', onStdoutData) channel.stderr.on('data', onStderrData) channel.on('close', onClose) + if (signal?.aborted) { + onAbort() + } }) } diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index eddb0e9f0ba..ec825cdc4b6 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -69,7 +69,9 @@ vi.mock('./ssh-connection-utils', () => ({ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' +import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' import type { SshConnection } from './ssh-connection' +import type * as SshRemoteNodeResolution from './ssh-remote-node-resolution' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS @@ -90,6 +92,7 @@ function extractWindowsMarkerPath(script: string): string { function makeMockConnection(): SshConnection { return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), exec: vi.fn().mockResolvedValue({ on: vi.fn(), stderr: { on: vi.fn() }, @@ -161,6 +164,259 @@ describe('deployAndLaunchRelay', () => { expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) }) + it('resolves node concurrently with remote home, not after the install-state chain', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + + let markNodeResolutionStarted: () => void = () => {} + const nodeResolutionStarted = new Promise((resolve) => { + markNodeResolutionStarted = resolve + }) + vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => { + markNodeResolutionStarted() + return Promise.resolve('/usr/bin/node') + }) + + // Hold the first install-state step open. The optimization starts the node + // branch before the remote-home -> install-check chain finishes. + let releaseRemoteHome: (home: string) => void = () => {} + mockExecCommand.mockReturnValueOnce( + new Promise((resolve) => { + releaseRemoteHome = resolve + }) + ) + + const deployPromise = deployAndLaunchRelay(conn) + let assertionError: unknown + let deployError: unknown + try { + await nodeResolutionStarted + + expect(isRelayAlreadyInstalled).not.toHaveBeenCalled() + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + } catch (err) { + assertionError = err + } finally { + // Drain the rest of the happy path so a failed assertion does not leave + // the deploy promise pending until its 300s timeout. + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + releaseRemoteHome('/home/user') + deployError = await deployPromise.then( + () => undefined, + (err: unknown) => err + ) + } + if (assertionError) { + throw assertionError + } + if (deployError) { + throw deployError + } + }) + + it('keeps bootstrap sequential when the connection cannot run concurrent exec commands', async () => { + const conn = makeMockConnection() + vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(false) + const mockExecCommand = vi.mocked(execCommand) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + let releaseRemoteHome: (home: string) => void = () => {} + let remoteHomeProbeStarted: () => void = () => {} + const remoteHomeProbeStartedPromise = new Promise((resolve) => { + remoteHomeProbeStarted = resolve + }) + mockExecCommand.mockReturnValueOnce( + new Promise((resolve) => { + remoteHomeProbeStarted() + releaseRemoteHome = resolve + }) + ) + + const deployPromise = deployAndLaunchRelay(conn) + await remoteHomeProbeStartedPromise + expect(resolveRemoteNodePath).not.toHaveBeenCalled() + + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + releaseRemoteHome('/home/user') + await deployPromise + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + }) + + it('falls back to sequential bootstrap when concurrent SSH sessions are refused', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + const { resolveRemoteNodePath: resolveRemoteNodePathActual } = await vi.importActual< + typeof SshRemoteNodeResolution + >('./ssh-remote-node-resolution') + let fallbackInstallStateCompleted = false + vi.mocked(resolveRemoteNodePath) + .mockImplementationOnce(resolveRemoteNodePathActual) + .mockImplementationOnce(() => { + if (!fallbackInstallStateCompleted) { + throw new Error('Sequential fallback resolved node before install state finished') + } + return Promise.resolve('/usr/bin/node') + }) + vi.mocked(isRelayAlreadyInstalled) + .mockImplementationOnce(async (_conn, _dir, _host, options) => { + expect(options?.rethrowSessionLimitErrors).toBe(true) + return true + }) + .mockImplementationOnce(async (_conn, _dir, _host, options) => { + expect(options?.rethrowSessionLimitErrors).toBeUndefined() + fallbackInstallStateCompleted = true + return true + }) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME + mockExecCommand.mockRejectedValueOnce(sessionLimitError) // concurrent node path probe + mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + + await deployAndLaunchRelay(conn) + + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(2) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) + }) + + it('falls back to sequential bootstrap when the install-state probe hits a session limit', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + vi.mocked(isRelayAlreadyInstalled) + .mockImplementationOnce(async (_conn, _dir, _host, options) => { + if (!options?.rethrowSessionLimitErrors) { + return true + } + throw sessionLimitError + }) + .mockResolvedValueOnce(true) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME + mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + + await deployAndLaunchRelay(conn) + + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(2) + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[0]?.[3]).toMatchObject({ + rethrowSessionLimitErrors: true + }) + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[1]?.[3]).toBeUndefined() + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) + }) + + it('does not retry bootstrap for non-session failures', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const nodeError = new Error('Node.js not found on remote host') + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(nodeError) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME + + await expect(deployAndLaunchRelay(conn)).rejects.toBe(nodeError) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(1) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + }) + + it('aborts a pending sibling probe and preserves a non-session install-state failure', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + let nodeProbeAborted = false + vi.mocked(resolveRemoteNodePath).mockImplementationOnce((_conn, _host, options) => { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => { + nodeProbeAborted = true + const abortError = new Error('aborted') + abortError.name = 'AbortError' + reject(abortError) + }) + }) + }) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('relative-home') // invalid install-state $HOME + + const timedDeploy = Promise.race([ + deployAndLaunchRelay(conn), + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('deploy did not fail promptly')), 100) + }) + ]) + + await expect(timedDeploy).rejects.toThrow(/Remote home is not a valid path/) + expect(nodeProbeAborted).toBe(true) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + }) + + it('does not retry when a session-limit failure races with a real install-state failure', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + const installError = new Error('permission denied while checking relay install') + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(sessionLimitError) + vi.mocked(isRelayAlreadyInstalled).mockRejectedValueOnce(installError) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME + + await expect(deployAndLaunchRelay(conn)).rejects.toBe(installError) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(1) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + }) + + it('does not retry until the surviving first-attempt probe settles', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + let releaseRemoteHome: (home: string) => void = () => {} + let remoteHomeSettled = false + mockExecCommand.mockReturnValueOnce( + new Promise((resolve) => { + releaseRemoteHome = (home: string) => { + remoteHomeSettled = true + resolve(home) + } + }) + ) + vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => Promise.reject(sessionLimitError)) + vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => { + if (!remoteHomeSettled) { + throw new Error('Sequential fallback started before first install-state probe settled') + } + return Promise.resolve('/usr/bin/node') + }) + + const deployPromise = deployAndLaunchRelay(conn) + await vi.waitFor(() => expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setImmediate(resolve)) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + + mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + releaseRemoteHome('/home/user') + await deployPromise + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) + }) + it('defaults fresh relays to keep-alive-until-reset', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index bee9f63de8b..78e7dec4443 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -42,6 +42,7 @@ import { import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' +import { isSshSessionLimitError } from './ssh-session-limit-error' import { isWindowsRelayPipePath, relayEndpointForHost, @@ -80,11 +81,12 @@ function execHostCommand( conn: SshConnection, hostPlatform: RemoteHostPlatform, command: string, - options?: { timeoutMs?: number } + options?: { timeoutMs?: number; signal?: AbortSignal } ): Promise { return execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(hostPlatform), - timeoutMs: options?.timeoutMs + timeoutMs: options?.timeoutMs, + signal: options?.signal }) } @@ -122,6 +124,119 @@ export async function deployAndLaunchRelay( } } +/** + * Resolve the remote home, derive the versioned relay directory, and check + * whether the relay is already installed there. + * + * Why: extracted so the deploy can run this chain concurrently with node-path + * resolution (the two are independent). Home and install-check stay sequential + * here because the install-check needs the resolved directory. + */ +async function resolveRemoteInstallState( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + fullVersion: string, + options?: { rethrowSessionLimitErrors?: boolean; signal?: AbortSignal } +): Promise<{ remoteHome: string; remoteRelayDir: string; alreadyInstalled: boolean }> { + // Why: SFTP does not expand `~`, so we must resolve the remote home + // explicitly with the host's native shell and normalize it before use. + const remoteHome = normalizeRemoteHome( + await execHostCommand(conn, hostPlatform, readRemoteHomeCommand(hostPlatform), { + signal: options?.signal + }), + hostPlatform + ) + // Why: we only interpolate $HOME into single-quoted shell strings later, so + // this validation only needs to reject obviously unsafe control characters. + // Allow spaces and non-ASCII so valid home directories are not rejected. + if (!validateRemoteHome(remoteHome, hostPlatform)) { + throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) + } + const remoteRelayDir = computeRemoteRelayDir(remoteHome, fullVersion, hostPlatform.pathFlavor) + const probeOptions = + options?.rethrowSessionLimitErrors || options?.signal + ? { + rethrowSessionLimitErrors: options.rethrowSessionLimitErrors, + signal: options.signal + } + : undefined + const alreadyInstalled = await isRelayAlreadyInstalled( + conn, + remoteRelayDir, + hostPlatform, + probeOptions + ) + return { remoteHome, remoteRelayDir, alreadyInstalled } +} + +type RelayBootstrapState = { + remoteHome: string + remoteRelayDir: string + alreadyInstalled: boolean + nodePath: string +} + +async function resolveRelayBootstrapStateSequentially( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + fullVersion: string +): Promise { + const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion) + const nodePath = await resolveRemoteNodePath(conn, hostPlatform) + return { ...installState, nodePath } +} + +async function resolveRelayBootstrapState( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + fullVersion: string +): Promise { + if (!conn.canRunConcurrentExecCommands()) { + return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion) + } + const abortController = new AbortController() + const installStatePromise = resolveRemoteInstallState(conn, hostPlatform, fullVersion, { + rethrowSessionLimitErrors: true, + signal: abortController.signal + }) + const nodePathPromise = resolveRemoteNodePath(conn, hostPlatform, { + rethrowSessionLimitErrors: true, + signal: abortController.signal + }) + try { + const [installState, nodePath] = await Promise.all([installStatePromise, nodePathPromise]) + return { ...installState, nodePath } + } catch (err) { + abortController.abort() + const settled = await Promise.allSettled([installStatePromise, nodePathPromise]) + if (!isSshSessionLimitError(err)) { + throw err + } + const nonSessionFailure = settled.find( + (result) => + result.status === 'rejected' && + !isSshSessionLimitError(result.reason) && + !isAbortError(result.reason) + ) + if (nonSessionFailure?.status === 'rejected') { + throw nonSessionFailure.reason + } + console.warn( + '[ssh-relay] Concurrent bootstrap probes hit the remote SSH session limit; retrying sequentially.' + ) + return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion) + } +} + +function isAbortError(err: unknown): boolean { + return err instanceof Error && err.name === 'AbortError' +} + +/** + * Detect the remote platform, resolve install state and node path, install the + * relay if it is not already present, then launch it and return the transport. + * Inner implementation wrapped by `deployAndLaunchRelay` with an overall timeout. + */ async function deployAndLaunchRelayInner( conn: SshConnection, onProgress?: (status: string) => void, @@ -152,25 +267,17 @@ async function deployAndLaunchRelayInner( // docs/ssh-relay-versioned-install-dirs.md "Data Flow: Upstream Error". const fullVersion = readLocalFullVersion(localRelayDir) - // Why: SFTP does not expand `~`, so we must resolve the remote home - // explicitly with the host's native shell and normalize it before use. - const remoteHome = normalizeRemoteHome( - await execHostCommand(conn, hostPlatform, readRemoteHomeCommand(hostPlatform)), - hostPlatform - ) - // Why: we only interpolate $HOME into single-quoted shell strings later, so - // this validation only needs to reject obviously unsafe control characters. - // Allow spaces and non-ASCII so valid home directories are not rejected. - if (!validateRemoteHome(remoteHome, hostPlatform)) { - throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) - } - const remoteRelayDir = computeRemoteRelayDir(remoteHome, fullVersion, hostPlatform.pathFlavor) - console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) - onProgress?.('Checking existing relay...') - const alreadyInstalled = await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform) + // Why: the remote-home -> install-check chain and node resolution are + // independent (both only need hostPlatform, not each other's results), yet + // each is a separate SSH exec round trip. Run them concurrently so the deploy + // pays one round trip instead of two for this phase. Most failures stay + // fail-fast; remotes that reject overlapping session channels retry the old + // sequential order so restrictive SSH servers keep working. + const { remoteHome, remoteRelayDir, alreadyInstalled, nodePath } = + await resolveRelayBootstrapState(conn, hostPlatform, fullVersion) + console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) - const nodePath = await resolveRemoteNodePath(conn, hostPlatform) if (alreadyInstalled) { await repairInstalledNativeDeps(conn, remoteRelayDir, platform, hostPlatform, nodePath) diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index b8bfcfeb7dc..0cedf1aff05 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -109,6 +109,7 @@ function makeMockConnection(capture: SftpWriteCapture): SshConnection { end: vi.fn() }) return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), exec: vi.fn().mockResolvedValue({ on: vi.fn(), stderr: { on: vi.fn() }, diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 29ea274c309..8cc46e8b151 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -88,6 +88,26 @@ describe('isRelayAlreadyInstalled', () => { expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false) }) + it('keeps default probe failures as not installed for SSH session-limit-shaped errors', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + mockExec.mockRejectedValueOnce(sessionLimitError) + + await expect(isRelayAlreadyInstalled(conn, '/r')).resolves.toBe(false) + }) + + it('rethrows SSH session-limit errors in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + mockExec.mockRejectedValueOnce(sessionLimitError) + + await expect( + isRelayAlreadyInstalled(conn, '/r', undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + it('checks for relay.js AND .install-complete in addition to the dir', async () => { mockExec.mockResolvedValueOnce('OK') await isRelayAlreadyInstalled(conn, '/r') diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index ab3c88fb6ee..4074e31f673 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -36,6 +36,7 @@ import { type RemotePathFlavor } from './ssh-remote-platform' import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' +import { isSshSessionLimitError } from './ssh-session-limit-error' // Why: the GC pass and the version-dir parser must agree on what counts as a // relay install dir. Single source of truth for both. The pattern matches the @@ -64,12 +65,21 @@ const INSTALL_LOCK_TIMEOUT_MS = 120_000 const INSTALL_LOCK_STALE_MS = 120_000 const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') +type RelayInstalledProbeOptions = { + rethrowSessionLimitErrors?: boolean + signal?: AbortSignal +} + function execHostCommand( conn: SshConnection, host: RemoteHostPlatform, - command: string + command: string, + options?: { signal?: AbortSignal } ): Promise { - return execCommand(conn, command, { wrapCommand: host.commandDialect !== 'powershell' }) + return execCommand(conn, command, { + wrapCommand: host.commandDialect !== 'powershell', + signal: options?.signal + }) } /** @@ -124,16 +134,21 @@ export function computeRemoteRelayDir( export async function isRelayAlreadyInstalled( conn: SshConnection, remoteRelayDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: RelayInstalledProbeOptions ): Promise { try { const probe = await execHostCommand( conn, host, - probeRelayInstalledCommand(host, remoteRelayDir) + probeRelayInstalledCommand(host, remoteRelayDir), + { signal: options?.signal } ) return probe.trim() === 'OK' - } catch { + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } return false } } diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts index b007c8fd17c..23ea9097540 100644 --- a/src/main/ssh/ssh-remote-node-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -4,6 +4,7 @@ import os from 'node:os' import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' const execCommandMock = vi.hoisted(() => vi.fn()) @@ -270,6 +271,66 @@ describe('resolveRemoteNodePath', () => { await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) }) + it('keeps the default path-probe fallback for SSH session-limit-shaped errors', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock + .mockRejectedValueOnce(sessionLimitError) + .mockResolvedValueOnce('/bin/bash') + .mockResolvedValueOnce('\n') + + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + }) + + it('rethrows SSH session-limit errors from the path probe in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock.mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + + it('rethrows SSH session-limit errors from candidate version checks in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock + .mockResolvedValueOnce('/usr/local/bin/node\n') + .mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + + it('rethrows SSH session-limit errors from login-shell resolution in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock.mockResolvedValueOnce('\n').mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + + it('rethrows SSH session-limit errors from Windows node resolution in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock.mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), { + rethrowSessionLimitErrors: true + }) + ).rejects.toBe(sessionLimitError) + }) + it('throws when every candidate across both strategies is below the minimum', async () => { execCommandMock .mockResolvedValueOnce('/old/node\n') // path probe diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index a5ec9b9fd05..4117ddf97ef 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -4,6 +4,7 @@ import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform' import { powerShellCommand } from './ssh-remote-powershell' import { execCommand } from './ssh-relay-deploy-helpers' +import { isSshSessionLimitError } from './ssh-session-limit-error' // Why: the relay requires Node.js 18+. Version managers like nvm keep every // installed version on disk, so a naive "highest version" glob can hand back @@ -15,12 +16,18 @@ const MIN_NODE_MAJOR = 18 // hang a login shell, so keep this short. const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000 +type RemoteNodeResolutionOptions = { + rethrowSessionLimitErrors?: boolean + signal?: AbortSignal +} + export async function resolveRemoteNodePath( conn: SshConnection, - host?: RemoteHostPlatform + host?: RemoteHostPlatform, + options?: RemoteNodeResolutionOptions ): Promise { if (host && isWindowsRemoteHost(host)) { - return resolveRemoteWindowsNodePath(conn) + return resolveRemoteWindowsNodePath(conn, options) } // Strategy 1: probe well-known install directories for every common Node @@ -28,14 +35,14 @@ export async function resolveRemoteNodePath( // This doesn't depend on shell startup-file semantics — bash -lc skips // .bashrc and zsh -lc skips .zshrc, but those are exactly the files where // nvm/mise/asdf hooks live. Probing directories directly is deterministic. - const probedPath = await tryResolveViaKnownPaths(conn) + const probedPath = await tryResolveViaKnownPaths(conn, options) if (probedPath) { return probedPath } // Strategy 2 (fallback): ask the user's login shell. Catches custom PATH // setups in ~/.profile / ~/.bash_profile that the probes don't cover. - const loginShellPath = await tryResolveViaLoginShell(conn) + const loginShellPath = await tryResolveViaLoginShell(conn, options) if (loginShellPath) { return loginShellPath } @@ -47,7 +54,10 @@ export async function resolveRemoteNodePath( // plus system package-manager locations. Every probe runs unconditionally so // a missing directory prints nothing rather than short-circuiting later // probes. Returns the first candidate that meets the minimum version. -async function tryResolveViaKnownPaths(conn: SshConnection): Promise { +async function tryResolveViaKnownPaths( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { const script = ` command -v node 2>/dev/null nvm_dirs=\${NVM_DIR:-"$HOME/.nvm"} @@ -94,7 +104,7 @@ true ` try { - const result = await execCommand(conn, script) + const result = await execCommand(conn, script, { signal: options?.signal }) const seen = new Set() for (const line of result.split('\n')) { const candidate = line.trim() @@ -102,12 +112,15 @@ true continue } seen.add(candidate) - if (await nodeMeetsVersionRequirement(conn, candidate)) { + if (await nodeMeetsVersionRequirement(conn, candidate, options)) { console.log(`[ssh-relay] Found node via path probe: ${candidate}`) return candidate } } - } catch { + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } // Fall through to login shell. } return null @@ -116,14 +129,18 @@ true // Run `command -v node` under the user's login shell, then verify the result // meets the minimum version. Returns null on any failure (shell missing, no // node found, version too old, timeout) so callers fall through to the error. -async function tryResolveViaLoginShell(conn: SshConnection): Promise { +async function tryResolveViaLoginShell( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { try { // Why: $SHELL is the user's configured login shell (set by chsh / passwd). // Using it — rather than hardcoding bash — means zsh/fish users whose // custom PATH hooks live in profile files get coverage too. We fall back // to sh if $SHELL is unset (rare, e.g. restricted accounts). const shellResult = await execCommand(conn, 'echo "${SHELL:-/bin/sh}"', { - timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS + timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS, + signal: options?.signal }) const shell = shellResult.trim().split('\n')[0] if (!shell) { @@ -132,18 +149,22 @@ async function tryResolveViaLoginShell(conn: SshConnection): Promise { try { const versionOutput = await execCommand(conn, `${shellEscape(nodePath)} --version`, { - wrapCommand: false + wrapCommand: false, + signal: options?.signal }) const match = versionOutput.trim().match(/^v?(\d+)/) if (!match) { @@ -174,13 +197,19 @@ async function nodeMeetsVersionRequirement( } const major = Number.parseInt(match[1]!, 10) return major >= MIN_NODE_MAJOR - } catch { + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } // Binary missing or fails to run — not usable. return false } } -async function resolveRemoteWindowsNodePath(conn: SshConnection): Promise { +async function resolveRemoteWindowsNodePath( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { const script = [ '$paths = @()', '$cmd = Get-Command node.exe -ErrorAction SilentlyContinue', @@ -199,14 +228,20 @@ async function resolveRemoteWindowsNodePath(conn: SshConnection): Promise { + it('matches ssh2 open failures with the resource-shortage reason code', () => { + expect( + isSshSessionLimitError( + Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 4 }) + ) + ).toBe(true) + }) + + it('matches OpenSSH mux and MaxSessions failures', () => { + expect( + isSshSessionLimitError( + new Error( + 'mux_client_request_session: session request failed: Session open refused by peer' + ) + ) + ).toBe(true) + expect( + isSshSessionLimitError(new Error('open failed: MaxSessions limit reached on remote host')) + ).toBe(true) + }) + + it('does not match generic channel-open failures without a session-limit reason', () => { + expect( + isSshSessionLimitError( + Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 1 }) + ) + ).toBe(false) + expect( + isSshSessionLimitError( + Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 2 }) + ) + ).toBe(false) + expect( + isSshSessionLimitError( + Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 3 }) + ) + ).toBe(false) + }) + + it('does not match unrelated command failures', () => { + expect( + isSshSessionLimitError(new Error('Command "node" failed (exit 1): Node.js not found')) + ).toBe(false) + expect(isSshSessionLimitError(new Error('channel open failure while parsing output'))).toBe( + false + ) + expect( + isSshSessionLimitError( + new Error('open failed: administratively prohibited: forwarding disabled') + ) + ).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-session-limit-error.ts b/src/main/ssh/ssh-session-limit-error.ts new file mode 100644 index 00000000000..aed848525b8 --- /dev/null +++ b/src/main/ssh/ssh-session-limit-error.ts @@ -0,0 +1,19 @@ +export function isSshSessionLimitError(err: unknown): boolean { + if (!(err instanceof Error)) { + return false + } + const reason = (err as { reason?: unknown }).reason + const message = err.message.toLowerCase() + if ( + reason === 4 && + (message.includes('channel open failure') || message.includes('open failed')) + ) { + return true + } + return ( + message.includes('no free channels available') || + message.includes('maxsessions') || + message.includes('session open refused') || + (message.includes('mux_client_request_session') && message.includes('session request failed')) + ) +} From e44daf37a3a61ab53cde98953406d2c935db48b9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:50:15 -0700 Subject: [PATCH 22/70] Match main terminal mirror character widths to the renderer (#7148) --- .../headless-emulator-unicode-width.test.ts | 28 +++++++++++++++++++ src/main/daemon/headless-emulator.ts | 10 +++++++ .../src/lib/pane-manager/pane-lifecycle.ts | 2 +- .../lib/pane-manager/pane-terminal-options.ts | 2 +- .../terminal-unicode-provider.ts} | 0 5 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 src/main/daemon/headless-emulator-unicode-width.test.ts rename src/{renderer/src/lib/pane-manager/pane-terminal-unicode-provider.ts => shared/terminal-unicode-provider.ts} (100%) diff --git a/src/main/daemon/headless-emulator-unicode-width.test.ts b/src/main/daemon/headless-emulator-unicode-width.test.ts new file mode 100644 index 00000000000..f358ce79189 --- /dev/null +++ b/src/main/daemon/headless-emulator-unicode-width.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' + +// Why these oracles: agent TUIs position the cursor assuming the widths the +// on-screen xterm uses (Unicode 11 + ZWJ joining). If this mirror measures a +// preceding emoji differently, the positioned write lands on the wrong cell +// and the mirrored row tears — which snapshot restores then paint back. +describe('headless emulator unicode widths', () => { + it('advances emoji as two cells so positioned writes land like the renderer', async () => { + const emulator = new HeadlessEmulator({ cols: 40, rows: 4 }) + // 🤖 occupies columns 1-2 under Unicode 11 (one cell under the v6 default), + // so "A" is at column 3, "B" at 4, and column 5 overwrites just past "B". + await emulator.write('\x1b[H\u{1F916}AB') + await emulator.write('\x1b[1;5HZ') + expect(emulator.getVisibleLines()[0]).toBe('\u{1F916}ABZ') + emulator.dispose() + }) + + it('joins ZWJ emoji into one wide pair like the renderer provider', async () => { + const emulator = new HeadlessEmulator({ cols: 40, rows: 4 }) + // 👩‍💻 (woman + ZWJ + laptop) renders as a single two-cell glyph in CLIs; + // plain Unicode 11 would budget four cells and shift everything after it. + await emulator.write('\x1b[H\u{1F469}\u{200D}\u{1F4BB}X') + await emulator.write('\x1b[1;3HY') + expect(emulator.getVisibleLines()[0]).toBe('\u{1F469}\u{200D}\u{1F4BB}Y') + emulator.dispose() + }) +}) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index e2e396170aa..ed9e9345d87 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -1,6 +1,8 @@ import './xterm-env-polyfill' import { Terminal } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider' import { extractLastOscTitle } from '../../shared/agent-detection' import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction' @@ -52,6 +54,14 @@ export class HeadlessEmulator { this.serializer = new SerializeAddon() this.terminal.loadAddon(this.serializer) + // Why: this mirror must measure character widths exactly like the + // renderer's xterm (Unicode 11 + ZWJ emoji joining). With the default v6 + // tables, emoji-dense rows (agent status lines) advance the cursor + // differently here than on screen, so the mirrored buffer accumulates + // cell-shifted tears that snapshot restores then paint back as garbage. + this.terminal.loadAddon(new Unicode11Addon()) + activateOrcaTerminalUnicodeProvider(this.terminal) + // Why no onData wiring: this emulator exists purely for state tracking // (snapshots, cwd, mode flags). It MUST NOT respond to terminal query // sequences (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The emulator parses diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index efdbea455ff..7756e7904c6 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -12,7 +12,7 @@ import { detachPaneFitResizeObserver } from './pane-fit-resize-observer' import { clearPendingSplitScrollRestore } from './pane-split-scroll' -import { activateOrcaTerminalUnicodeProvider } from './pane-terminal-unicode-provider' +import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal-unicode-provider' import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts index 89bbaf4b118..911d3fa7545 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts @@ -59,7 +59,7 @@ export function buildDefaultTerminalOptions(): ITerminalOptions { // this as a gutter, costing ~1 column per pane — accepted tradeoff so the // scrollbar never covers content (evidence in PR #5051). The v1.4.51 // table corruption #4877 fixed by zeroing this was actually the ZWJ - // width bug; it stays fixed by pane-terminal-unicode-provider.ts. Width + // width bug; it stays fixed by shared/terminal-unicode-provider.ts. Width // also enables the overview ruler, whose border is hidden in // composeActiveTerminalTheme. width: 7 diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-unicode-provider.ts b/src/shared/terminal-unicode-provider.ts similarity index 100% rename from src/renderer/src/lib/pane-manager/pane-terminal-unicode-provider.ts rename to src/shared/terminal-unicode-provider.ts From 2d4b46b44ffe7b47668fdc899601b250ac081b5a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:51:38 +0000 Subject: [PATCH 23/70] release: v1.4.119-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 484c9df78fb..7d68654a7ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.118", + "version": "1.4.119-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 4782b8d3bf51ef6b0b426cf4563a6567a90a4578 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:21:07 +0000 Subject: [PATCH 24/70] release: v1.4.119 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7d68654a7ca..57b6c4638c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.119-rc.0", + "version": "1.4.119", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From ce997b001dc5552c7038231961c3a0ec5dfaa1db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:35:04 +0000 Subject: [PATCH 25/70] release: v1.4.120-rc.0 [rc-slot:2026-07-02-15] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 57b6c4638c0..2fb3d623d31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.119", + "version": "1.4.120-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 2ce9314acb8ac1852abb306ab596dfbc10d2ecb2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:41:23 -0700 Subject: [PATCH 26/70] Improve Windows terminal performance: retain WebGL contexts, warm first ConPTY (#7085) --- ...ro-windows-apphang-terminal-activation.mjs | 196 +++ .../apphang-report-summary.mjs | 77 ++ .../electron-dev-session.mjs | 331 +++++ .../windows-apphang-repro/repro-timing.mjs | 47 + .../terminal-activation-scenario.mjs | 486 ++++++++ .../wsl-workspace-fixture.mjs | 135 +++ notes/windows-perf-progress.md | 63 + src/main/daemon/daemon-entry.ts | 3 + src/main/daemon/windows-conpty-warmup.test.ts | 91 ++ src/main/daemon/windows-conpty-warmup.ts | 45 + src/main/ipc/pty-spawn-timing.ts | 41 + src/main/ipc/pty.test.ts | 214 +++- src/main/ipc/pty.ts | 166 ++- .../local-pty-utils-windows-fallback.test.ts | 4 +- src/main/providers/local-pty-utils.ts | 20 +- .../src/lib/pane-manager/pane-lifecycle.ts | 6 +- .../src/lib/pane-manager/pane-manager.ts | 5 + .../pane-manager/pane-rendering-control.ts | 33 +- .../pane-webgl-context-retention.test.ts | 235 ++++ .../pane-webgl-context-retention.ts | 56 + .../pane-webgl-refresh-lifecycle.test.ts | 24 + .../lib/pane-manager/pane-webgl-renderer.ts | 39 +- ...erf-baseline-2026-07-02T07-23-46-933Z.json | 1041 ++++++++++++++++ ...perf-fix-a-b-2026-07-02T07-46-58-178Z.json | 1058 ++++++++++++++++ ...perf-fix-abd-2026-07-02T07-51-00-936Z.json | 1061 +++++++++++++++++ ...erf-fix-abdf-2026-07-02T07-59-11-820Z.json | 1058 ++++++++++++++++ tools/benchmarks/terminal-perf-bench.mjs | 621 ++++++++++ 27 files changed, 7103 insertions(+), 53 deletions(-) create mode 100644 config/scripts/repro-windows-apphang-terminal-activation.mjs create mode 100644 config/scripts/windows-apphang-repro/apphang-report-summary.mjs create mode 100644 config/scripts/windows-apphang-repro/electron-dev-session.mjs create mode 100644 config/scripts/windows-apphang-repro/repro-timing.mjs create mode 100644 config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs create mode 100644 config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs create mode 100644 src/main/daemon/windows-conpty-warmup.test.ts create mode 100644 src/main/daemon/windows-conpty-warmup.ts create mode 100644 src/main/ipc/pty-spawn-timing.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-webgl-context-retention.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-webgl-context-retention.ts create mode 100644 tools/benchmarks/results/terminal-perf-baseline-2026-07-02T07-23-46-933Z.json create mode 100644 tools/benchmarks/results/terminal-perf-fix-a-b-2026-07-02T07-46-58-178Z.json create mode 100644 tools/benchmarks/results/terminal-perf-fix-abd-2026-07-02T07-51-00-936Z.json create mode 100644 tools/benchmarks/results/terminal-perf-fix-abdf-2026-07-02T07-59-11-820Z.json create mode 100644 tools/benchmarks/terminal-perf-bench.mjs diff --git a/config/scripts/repro-windows-apphang-terminal-activation.mjs b/config/scripts/repro-windows-apphang-terminal-activation.mjs new file mode 100644 index 00000000000..73e7bd4a13f --- /dev/null +++ b/config/scripts/repro-windows-apphang-terminal-activation.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { + createWslFixture, + listWslDistros, + removeWslFixture +} from './windows-apphang-repro/wsl-workspace-fixture.mjs' +import { runGpuMode } from './windows-apphang-repro/terminal-activation-scenario.mjs' +import { summarizeResult } from './windows-apphang-repro/apphang-report-summary.mjs' + +const defaultCycles = 14 +const defaultOutputLines = 1_600 + +function parseArgs() { + const args = { + cycles: defaultCycles, + distro: null, + expect: 'none', + gpuModes: ['on'], + keep: false, + outputLines: defaultOutputLines, + reportPath: null, + sourceControl: true, + deadPtyReactivate: true + } + + for (const arg of process.argv.slice(2)) { + if (arg === '--help' || arg === '-h') { + printHelp() + process.exit(0) + } + if (arg === '--keep') { + args.keep = true + continue + } + if (arg === '--no-source-control') { + args.sourceControl = false + continue + } + if (arg === '--no-dead-pty-reactivate') { + args.deadPtyReactivate = false + continue + } + const [name, value] = arg.split('=', 2) + if (name === '--cycles') { + args.cycles = parsePositiveInt(name, value) + continue + } + if (name === '--distro') { + args.distro = value?.trim() || null + continue + } + if (name === '--expect') { + if (!['none', 'repro', 'pass'].includes(value)) { + throw new Error(`Unsupported --expect=${value}. Use none, repro, or pass.`) + } + args.expect = value + continue + } + if (name === '--gpu') { + const modes = (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + if (modes.length === 0 || modes.some((mode) => !['on', 'off', 'auto'].includes(mode))) { + throw new Error(`Unsupported --gpu=${value}. Use on, off, auto, or a comma-list.`) + } + args.gpuModes = modes + continue + } + if (name === '--output-lines') { + args.outputLines = parsePositiveInt(name, value) + continue + } + if (name === '--report') { + args.reportPath = value?.trim() || null + if (!args.reportPath) { + throw new Error('--report requires a file path.') + } + continue + } + throw new Error(`Unknown argument: ${arg}`) + } + + return args +} + +function printHelp() { + console.log(`Usage: + node config/scripts/repro-windows-apphang-terminal-activation.mjs [options] + +Options: + --expect=none|repro|pass none prints measurements, repro exits 0 only when hang evidence is observed, + pass exits 1 if hang evidence is observed. Default: none. + --gpu=on|off|auto[,mode...] Terminal GPU setting(s) to run. Default: on. + --cycles=N Activation/output cycles per GPU mode. Default: ${defaultCycles}. + --output-lines=N Lines emitted by each terminal stress command. Default: ${defaultOutputLines}. + --report=PATH Write full JSON evidence to PATH and print a compact summary to stdout. + --distro=NAME WSL distro to use. Default: first non docker-desktop distro. + --no-source-control Do not open Source Control during the stress loop. + --no-dead-pty-reactivate Do not kill PTYs and revisit workspaces after initial activation. + --keep Keep disposable WSL/userData fixtures after the run.`) +} + +function parsePositiveInt(name, value) { + const parsed = Number.parseInt(value ?? '', 10) + if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== value) { + throw new Error(`${name} requires a positive integer.`) + } + return parsed +} + +async function main() { + if (process.platform !== 'win32') { + throw new Error('This repro harness is intentionally Windows-only.') + } + const args = parseArgs() + const distros = listWslDistros() + const distro = args.distro ?? distros[0] + if (!distro) { + throw new Error('No user WSL distro found. Install/enable WSL or pass --distro=NAME.') + } + console.log( + `[apphang-repro] issue=https://github.com/stablyai/orca/issues/6874 distro=${distro} gpuModes=${args.gpuModes.join(',')} cycles=${args.cycles}` + ) + const fixture = createWslFixture(distro) + console.log( + `[apphang-repro] fixture repo=${fixture.repoUncPath} plain=${fixture.plainUncPath} base=${fixture.baseLinuxPath}` + ) + + const results = [] + try { + for (const gpuMode of args.gpuModes) { + results.push(await runGpuMode(gpuMode, args, fixture)) + if (args.expect === 'repro' && results.at(-1)?.reproduced) { + break + } + } + } finally { + if (!args.keep) { + removeWslFixture(fixture) + } + } + + const payload = { + issue: 'https://github.com/stablyai/orca/issues/6874', + expect: args.expect, + distro, + fixture: args.keep ? fixture : { removed: true, baseLinuxPath: fixture.baseLinuxPath }, + summary: results.map(summarizeResult), + results + } + if (args.reportPath) { + const reportPath = path.resolve(args.reportPath) + mkdirSync(path.dirname(reportPath), { recursive: true }) + writeFileSync(reportPath, `${JSON.stringify(payload, null, 2)}\n`) + console.log(JSON.stringify({ reportPath, summary: payload.summary }, null, 2)) + } else { + console.log(JSON.stringify(payload, null, 2)) + } + + // Why: a broken harness run (CDP/setup/selector failure) proves nothing — + // it must fail both --expect=repro and --expect=pass rather than being + // miscounted as hang evidence or as a clean pass. + const harnessErrors = results.filter((result) => result.harnessError) + if (harnessErrors.length > 0) { + const harnessErrorLines = harnessErrors + .map((result) => ` gpu=${result.gpuMode}: ${result.harnessError}`) + .join('\n') + console.error( + `[apphang-repro] Harness failed in ${harnessErrors.length} run(s); result inconclusive:\n${harnessErrorLines}` + ) + if (args.expect !== 'none') { + process.exit(1) + } + } + const reproduced = results.some((result) => result.reproduced) + if (args.expect === 'repro' && !reproduced) { + console.error( + '[apphang-repro] Expected to reproduce the hang, but no hang evidence was observed.' + ) + process.exit(1) + } + if (args.expect === 'pass' && reproduced) { + console.error('[apphang-repro] Expected a clean pass, but hang evidence was observed.') + process.exit(1) + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)) + process.exit(1) +}) diff --git a/config/scripts/windows-apphang-repro/apphang-report-summary.mjs b/config/scripts/windows-apphang-repro/apphang-report-summary.mjs new file mode 100644 index 00000000000..b6d222cb4de --- /dev/null +++ b/config/scripts/windows-apphang-repro/apphang-report-summary.mjs @@ -0,0 +1,77 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' + +export function summarizeTrace(userDataDir) { + const tracePath = path.join(userDataDir, 'logs', 'main.trace.ndjson') + if (!existsSync(tracePath)) { + return { tracePath, exists: false } + } + const lines = readFileSync(tracePath, 'utf8').trim().split(/\r?\n/).filter(Boolean) + const parsed = [] + for (const line of lines.slice(-400)) { + try { + parsed.push(JSON.parse(line)) + } catch { + parsed.push({ raw: line }) + } + } + const interesting = parsed.filter((entry) => { + const name = String(entry.name ?? entry.event ?? entry.type ?? '') + const text = JSON.stringify(entry) + return ( + name.includes('git.exec') || + name.includes('renderer_memory') || + name.includes('sidebar_worktree_activate') || + text.includes('git.exec remote') || + text.includes('git.exec worktree') || + text.includes('sidebar_worktree_activate') + ) + }) + return { + tracePath, + exists: true, + totalLines: lines.length, + interestingTail: interesting.slice(-40) + } +} + +export function summarizeAppLogs(logs) { + const webglContextWarnings = logs.filter((entry) => + entry.line.includes('Too many active WebGL contexts') + ) + const webglContextLosses = logs.filter((entry) => + entry.line.toLowerCase().includes('webgl context lost') + ) + const gpuProcessEvents = logs.filter((entry) => /gpu|d3d|angle/i.test(entry.line)) + return { + lineCount: logs.length, + webglContextWarningCount: webglContextWarnings.length, + webglContextWarningsTail: webglContextWarnings.slice(-10), + webglContextLossCount: webglContextLosses.length, + webglContextLossesTail: webglContextLosses.slice(-10), + gpuProcessEventCount: gpuProcessEvents.length, + gpuProcessEventsTail: gpuProcessEvents.slice(-20) + } +} + +export function summarizeResult(result) { + const samples = result.samples.filter((sample) => sample && typeof sample === 'object') + const maxActivationMs = Math.max(0, ...samples.map((sample) => sample.activationMs ?? 0)) + const maxPtyWaitMs = Math.max(0, ...samples.map((sample) => sample.ptyWaitMs ?? 0)) + const maxMainIpcMs = Math.max(0, ...samples.map((sample) => sample.mainIpcMs ?? 0)) + const maxRendererDriftMs = Math.max(0, ...samples.map((sample) => sample.rendererMaxDriftMs ?? 0)) + return { + gpuMode: result.gpuMode, + reproduced: result.reproduced, + reproductionReason: result.reproductionReason, + harnessError: result.harnessError ?? null, + elapsedMs: result.elapsedMs, + sampleCount: samples.length, + maxActivationMs, + maxPtyWaitMs, + maxMainIpcMs, + maxRendererDriftMs, + finalWebglContextCounts: result.finalDiagnostics?.webglContextCounts ?? null, + appLogSummary: result.appLogSummary + } +} diff --git a/config/scripts/windows-apphang-repro/electron-dev-session.mjs b/config/scripts/windows-apphang-repro/electron-dev-session.mjs new file mode 100644 index 00000000000..89fddf4ef99 --- /dev/null +++ b/config/scripts/windows-apphang-repro/electron-dev-session.mjs @@ -0,0 +1,331 @@ +import { chromium } from '@playwright/test' +import { spawn } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { + appShutdownTimeoutMs, + cdpPollTimeoutMs, + delay, + pollUntil, + rendererActionTimeoutMs, + runWithTimeout +} from './repro-timing.mjs' +import { createCompletedOnboardingProfile } from './wsl-workspace-fixture.mjs' + +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) + +function isPortFree(port) { + return new Promise((resolve) => { + const server = net.createServer() + server.once('error', () => { + try { + server.close() + } catch {} + resolve(false) + }) + server.once('listening', () => server.close(() => resolve(true))) + server.listen(port, '127.0.0.1') + }) +} + +export async function pickFreePort() { + for (let port = 9533; port < 9633; port += 1) { + if (await isPortFree(port)) { + return port + } + } + throw new Error('Could not find a free CDP port in 9533..9632.') +} + +export function createGpuUserDataDirectory(gpuMode) { + const userDataDir = mkdtempSync(path.join(os.tmpdir(), `orca-apphang-${gpuMode}-userdata-`)) + createCompletedOnboardingProfile(userDataDir) + return userDataDir +} + +export function launchDevApp({ cdpPort, userDataDir }) { + const env = { ...process.env } + delete env.ELECTRON_RUN_AS_NODE + Object.assign(env, { + ELECTRON_ENABLE_LOGGING: '1', + ELECTRON_ENABLE_STACK_DUMPING: '1', + NODE_ENV: 'development', + ORCA_DEV_USER_DATA_PATH: userDataDir, + ORCA_SKIP_DEV_WEB_PREPARE: '1', + ORCA_STARTUP_DIAGNOSTICS: '1', + REMOTE_DEBUGGING_PORT: String(cdpPort), + VITE_EXPOSE_STORE: 'true' + }) + const child = spawn( + process.execPath, + [path.join('config', 'scripts', 'run-electron-vite-dev.mjs')], + { + cwd: rootDir, + env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + } + ) + const logs = [] + const collect = (source) => (chunk) => { + const text = chunk.toString() + for (const line of text.split(/\r?\n/).filter(Boolean)) { + logs.push({ source, line, at: Date.now() }) + console.log(`[apphang-repro:${source}] ${line}`) + } + } + child.stdout?.on('data', collect('stdout')) + child.stderr?.on('data', collect('stderr')) + return { child, logs } +} + +export async function stopDevApp(child) { + if (!child?.pid || child.exitCode !== null || child.signalCode !== null) { + return + } + await new Promise((resolve) => { + const timer = setTimeout(resolve, appShutdownTimeoutMs) + child.once('exit', () => { + clearTimeout(timer) + resolve() + }) + if (process.platform === 'win32') { + const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + killer.once('exit', () => undefined) + return + } + child.kill('SIGTERM') + }) +} + +async function waitForCdp(port) { + const url = `http://127.0.0.1:${port}/json` + const startedAt = Date.now() + while (Date.now() - startedAt < cdpPollTimeoutMs) { + try { + const response = await fetch(url) + if (response.ok) { + const targets = await response.json() + if (Array.isArray(targets) && targets.some((target) => target.type)) { + return targets + } + } + } catch {} + await delay(500) + } + throw new Error(`Timed out waiting for CDP targets on ${url}`) +} + +async function getMainPage(browser) { + const startedAt = Date.now() + while (Date.now() - startedAt < cdpPollTimeoutMs) { + for (const context of browser.contexts()) { + const pages = context.pages() + const page = + pages.find((candidate) => + /^https?:\/\/127\.0\.0\.1:|^https?:\/\/localhost:/.test(candidate.url()) + ) ?? pages[0] + if (page) { + return page + } + } + await delay(250) + } + throw new Error('Timed out waiting for the Electron renderer page.') +} + +export async function connectToApp(cdpPort) { + await waitForCdp(cdpPort) + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`) + const page = await getMainPage(browser) + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }) + return { browser, page } +} + +export async function installRendererProbe(page) { + await page.evaluate(() => { + if (globalThis.__orcaApphangProbe) { + return + } + const probe = { + intervalMs: 50, + last: performance.now(), + maxDriftMs: 0, + samples: 0, + startedAt: performance.now(), + lastTickAt: performance.now() + } + const timer = setInterval(() => { + const now = performance.now() + const drift = Math.max(0, now - probe.last - probe.intervalMs) + probe.maxDriftMs = Math.max(probe.maxDriftMs, drift) + probe.last = now + probe.lastTickAt = now + probe.samples += 1 + }, probe.intervalMs) + globalThis.__orcaApphangProbe = { probe, timer } + }) +} + +export async function waitForStoreReady(page) { + await pollUntil( + 'renderer store exposure', + () => page.evaluate(() => Boolean(window.__store && window.api)), + Boolean, + 30_000 + ) + await pollUntil( + 'workspace session hydration', + () => + page.evaluate(() => { + const state = window.__store?.getState?.() + return Boolean(state?.workspaceSessionReady && state?.hydrationSucceeded) + }), + Boolean, + 45_000 + ) +} + +export async function collectRendererDiagnostics(page) { + if (!page) { + return null + } + try { + return await runWithTimeout( + 'renderer diagnostics', + () => + page.evaluate(async () => { + const timed = (label, promise) => + Promise.race([ + Promise.resolve(promise).then( + (value) => ({ value }), + (error) => ({ error: error instanceof Error ? error.message : String(error) }) + ), + new Promise((resolve) => + setTimeout(() => resolve({ error: `Timed out collecting ${label}` }), 1_000) + ) + ]) + const readWebglIdentity = () => { + const canvas = document.createElement('canvas') + let gl = null + try { + gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl') + if (!gl) { + return { available: false, vendor: null, renderer: null } + } + const debugInfo = gl.getExtension('WEBGL_debug_renderer_info') + if (!debugInfo) { + return { available: true, vendor: null, renderer: null } + } + return { + available: true, + vendor: String(gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) ?? '') || null, + renderer: String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) ?? '') || null + } + } catch (error) { + return { + available: false, + error: error instanceof Error ? error.message : String(error) + } + } finally { + try { + gl?.getExtension('WEBGL_lose_context')?.loseContext() + } catch {} + canvas.width = 0 + canvas.height = 0 + } + } + const allPaneManagersDiagnostics = Array.from( + window.__paneManagers?.entries?.() ?? [] + ).map(([managerTabId, paneManager]) => ({ + tabId: managerTabId, + diagnostics: paneManager?.getRenderingDiagnostics?.() ?? [] + })) + const webglContextCounts = allPaneManagersDiagnostics.reduce( + (acc, entry) => { + acc.managerCount += 1 + acc.paneCount += entry.diagnostics.length + for (const diagnostic of entry.diagnostics) { + if (diagnostic.hasWebgl) { + acc.attachedWebglCount += 1 + } + if (diagnostic.webglAttachmentDeferred) { + acc.deferredWebglCount += 1 + } + } + return acc + }, + { attachedWebglCount: 0, deferredWebglCount: 0, managerCount: 0, paneCount: 0 } + ) + const state = window.__store?.getState?.() + const worktreeId = state?.activeWorktreeId ?? null + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null + const buffer = activePane?.terminal?.buffer?.active ?? null + return { + hasStore: Boolean(window.__store), + workspaceSessionReady: state?.workspaceSessionReady ?? null, + hydrationSucceeded: state?.hydrationSucceeded ?? null, + activeView: state?.activeView ?? null, + activeRepoId: state?.activeRepoId ?? null, + activeWorktreeId: worktreeId, + activeTabType: state?.activeTabType ?? null, + activeTabId: tabId, + terminalGpuAcceleration: state?.settings?.terminalGpuAcceleration ?? null, + repoCount: state?.repos?.length ?? null, + worktreeCountsByRepo: Object.fromEntries( + Object.entries(state?.worktreesByRepo ?? {}).map(([repoId, worktrees]) => [ + repoId, + worktrees.length + ]) + ), + ptyIdsByTabId: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : [], + paneManagerCount: window.__paneManagers?.size ?? null, + activePane: activePane + ? { + id: activePane.id ?? null, + leafId: activePane.leafId ?? null, + ptyId: activePane.container?.dataset?.ptyId ?? null, + cols: activePane.terminal?.cols ?? null, + rows: activePane.terminal?.rows ?? null, + buffer: buffer + ? { + baseY: buffer.baseY, + viewportY: buffer.viewportY, + cursorY: buffer.cursorY, + length: buffer.length + } + : null + } + : null, + renderingDiagnostics: manager?.getRenderingDiagnostics?.() ?? null, + allPaneManagersDiagnostics, + webglContextCounts, + webglIdentity: readWebglIdentity(), + rendererProbe: globalThis.__orcaApphangProbe?.probe ?? null, + ptySessions: await timed('PTY sessions', window.api?.pty?.listSessions?.()), + rendererDeliveryDebug: await timed( + 'renderer delivery debug', + window.api?.pty?.getRendererDeliveryDebugSnapshot?.() + ) + } + }), + rendererActionTimeoutMs + ) + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/config/scripts/windows-apphang-repro/repro-timing.mjs b/config/scripts/windows-apphang-repro/repro-timing.mjs new file mode 100644 index 00000000000..9f335d61435 --- /dev/null +++ b/config/scripts/windows-apphang-repro/repro-timing.mjs @@ -0,0 +1,47 @@ +export const cdpPollTimeoutMs = 90_000 +export const rendererActionTimeoutMs = 5_000 +export const activationTimeoutMs = 15_000 +export const ptyWaitTimeoutMs = 20_000 +export const terminalMarkerTimeoutMs = 45_000 +export const setupTimeoutMs = 90_000 +export const appShutdownTimeoutMs = 8_000 +export const severeRendererDriftMs = 2_000 +export const severeActivationMs = 5_000 +export const severeMainIpcMs = 2_000 +export const severePtyWaitMs = 10_000 + +export function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export async function runWithTimeout(label, action, timeoutMs) { + const actionPromise = Promise.resolve().then(action) + actionPromise.catch(() => undefined) + const result = await Promise.race([ + actionPromise.then( + (value) => ({ timedOut: false, value }), + (error) => ({ timedOut: false, error }) + ), + delay(timeoutMs).then(() => ({ timedOut: true })) + ]) + if (result.timedOut) { + throw new Error(`Timed out during ${label} after ${timeoutMs}ms.`) + } + if ('error' in result) { + throw result.error + } + return result.value +} + +export async function pollUntil(label, read, predicate, timeoutMs, intervalMs = 100) { + const startedAt = Date.now() + let lastValue = null + while (Date.now() - startedAt < timeoutMs) { + lastValue = await runWithTimeout(label, read, rendererActionTimeoutMs) + if (predicate(lastValue)) { + return lastValue + } + await delay(intervalMs) + } + throw new Error(`Timed out waiting for ${label}; last value: ${JSON.stringify(lastValue)}`) +} diff --git a/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs b/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs new file mode 100644 index 00000000000..db92c7cdf31 --- /dev/null +++ b/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs @@ -0,0 +1,486 @@ +import { + collectRendererDiagnostics, + connectToApp, + createGpuUserDataDirectory, + installRendererProbe, + launchDevApp, + pickFreePort, + stopDevApp, + waitForStoreReady +} from './electron-dev-session.mjs' +import { summarizeAppLogs, summarizeTrace } from './apphang-report-summary.mjs' +import { + activationTimeoutMs, + pollUntil, + ptyWaitTimeoutMs, + rendererActionTimeoutMs, + runWithTimeout, + setupTimeoutMs, + severeActivationMs, + severeMainIpcMs, + severePtyWaitMs, + severeRendererDriftMs, + terminalMarkerTimeoutMs +} from './repro-timing.mjs' +import { safeRemoveLocalDirectory } from './wsl-workspace-fixture.mjs' + +class ReproductionObservedError extends Error { + constructor(message, evidence) { + super(message) + this.name = 'ReproductionObservedError' + this.evidence = evidence + } +} + +async function setupAppFixture(page, fixture, gpuMode, sourceControl) { + return await runWithTimeout( + 'fixture registration in Orca', + () => + page.evaluate( + async ({ repoPath, plainPath, importedWorktreePaths, mode, openSourceControl }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is unavailable.') + } + const state = store.getState() + await state.fetchSettings?.() + await store.getState().updateSettings({ terminalGpuAcceleration: mode }) + + const addResult = await window.api.repos.add({ path: repoPath, kind: 'git' }) + if ('error' in addResult) { + throw new Error(addResult.error) + } + await store.getState().fetchRepos() + let nextState = store.getState() + const repo = + nextState.repos.find((candidate) => candidate.path === repoPath) ?? addResult.repo + await nextState.updateRepo(repo.id, { + externalWorktreeVisibility: 'show', + externalWorktreeVisibilityPromptDismissedAt: Date.now(), + importedExternalWorktreePaths: importedWorktreePaths, + externalWorktreeInboxBaselinePaths: importedWorktreePaths + }) + await store.getState().fetchWorktrees(repo.id, { requireAuthoritative: true }) + + const plainRepo = await store.getState().addNonGitFolder(plainPath) + if (!plainRepo) { + throw new Error('addNonGitFolder returned null.') + } + await store.getState().fetchWorktrees(plainRepo.id, { requireAuthoritative: true }) + + nextState = store.getState() + nextState.setSidebarOpen(true) + nextState.setGroupBy('none') + nextState.setSortBy('recent') + nextState.setShowActiveOnly(false) + nextState.setActiveView('terminal') + if (openSourceControl) { + nextState.setRightSidebarOpen(true) + nextState.setRightSidebarTab('source-control') + } + + const gitWorktrees = nextState.worktreesByRepo[repo.id] ?? [] + const plainWorktrees = nextState.worktreesByRepo[plainRepo.id] ?? [] + return { + repoId: repo.id, + repoPath: repo.path, + plainRepoId: plainRepo.id, + gitWorktrees: gitWorktrees.map((worktree) => ({ + id: worktree.id, + path: worktree.path, + displayName: worktree.displayName, + branch: worktree.branch, + isMainWorktree: worktree.isMainWorktree + })), + plainWorktrees: plainWorktrees.map((worktree) => ({ + id: worktree.id, + path: worktree.path, + displayName: worktree.displayName, + branch: worktree.branch, + isMainWorktree: worktree.isMainWorktree + })) + } + }, + { + repoPath: fixture.repoUncPath, + plainPath: fixture.plainUncPath, + importedWorktreePaths: fixture.worktreeUncPaths, + mode: gpuMode, + openSourceControl: sourceControl + } + ), + setupTimeoutMs + ) +} + +async function clickWorktreeCard(page, worktreeId) { + const rect = await runWithTimeout( + `locate worktree card ${worktreeId}`, + () => + page.evaluate((id) => { + const rows = Array.from(document.querySelectorAll('[data-worktree-id]')) + const row = rows.find((candidate) => candidate.getAttribute('data-worktree-id') === id) + if (!row) { + return null + } + row.scrollIntoView({ block: 'center', inline: 'nearest' }) + const surface = row.querySelector('[data-worktree-card-surface="true"]') ?? row + const bounds = surface.getBoundingClientRect() + if (bounds.width <= 0 || bounds.height <= 0) { + return null + } + return { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + width: bounds.width, + height: bounds.height + } + }, worktreeId), + rendererActionTimeoutMs + ) + if (!rect) { + throw new Error(`Could not find rendered worktree card for ${worktreeId}`) + } + await runWithTimeout( + `click worktree card ${worktreeId}`, + () => page.mouse.click(rect.x, rect.y), + rendererActionTimeoutMs + ) +} + +async function waitForActiveWorktree(page, worktreeId) { + return await pollUntil( + `active worktree ${worktreeId}`, + () => + page.evaluate((id) => { + const state = window.__store?.getState?.() + return { + activeWorktreeId: state?.activeWorktreeId ?? null, + activeTabId: state?.activeTabId ?? null, + activeTabType: state?.activeTabType ?? null, + tabs: state?.tabsByWorktree?.[id]?.map((tab) => tab.id) ?? [] + } + }, worktreeId), + (value) => value?.activeWorktreeId === worktreeId && value.activeTabType === 'terminal', + activationTimeoutMs + ) +} + +async function waitForActivePty(page, worktreeId) { + const startedAt = Date.now() + const value = await pollUntil( + `active PTY for ${worktreeId}`, + () => + page.evaluate((id) => { + const state = window.__store?.getState?.() + const tabId = + state?.activeWorktreeId === id && state.activeTabType === 'terminal' + ? state.activeTabId + : (state?.activeTabIdByWorktree?.[id] ?? null) + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null + const ptyId = pane?.container?.dataset?.ptyId ?? null + return { + tabId, + ptyId, + hasManager: Boolean(manager), + paneCount: manager?.getPanes?.()?.length ?? 0, + ptyIdsByTab: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : [] + } + }, worktreeId), + (value) => Boolean(value?.ptyId), + ptyWaitTimeoutMs + ) + return { ...value, waitMs: Date.now() - startedAt } +} + +function makeOutputCommand(marker, outputLines) { + const payload = 'x'.repeat(180) + return `printf '${marker}_START\\n'; i=1; while [ "$i" -le ${outputLines} ]; do printf '${marker}_%05d ${payload}\\n' "$i"; i=$((i+1)); done; printf '${marker}_DONE\\n'\r` +} + +async function getTerminalContent(page, charLimit = 20_000) { + return await runWithTimeout( + 'terminal content', + () => + page.evaluate((limit) => { + const state = window.__store?.getState?.() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null + return (pane?.serializeAddon?.serialize?.() ?? '').slice(-limit) + }, charLimit), + rendererActionTimeoutMs + ) +} + +async function sendOutputStress(page, ptyId, marker, outputLines) { + await runWithTimeout( + `write terminal stress ${marker}`, + () => + page.evaluate( + ({ id, command }) => { + window.api.pty.write(id, command) + }, + { id: ptyId, command: makeOutputCommand(marker, outputLines) } + ), + rendererActionTimeoutMs + ) + await pollUntil( + `terminal stress marker ${marker}`, + async () => (await getTerminalContent(page)).includes(`${marker}_DONE`), + Boolean, + terminalMarkerTimeoutMs, + 200 + ) +} + +async function pingMainIpc(page) { + const startedAt = Date.now() + await runWithTimeout( + 'main IPC ping', + () => page.evaluate(() => window.api.pty.listSessions()), + rendererActionTimeoutMs + ) + return Date.now() - startedAt +} + +async function readRendererProbe(page) { + return await runWithTimeout( + 'renderer probe read', + () => page.evaluate(() => globalThis.__orcaApphangProbe?.probe ?? null), + rendererActionTimeoutMs + ) +} + +async function killActivePty(page) { + return await runWithTimeout( + 'kill active PTY', + () => + page.evaluate(async () => { + const state = window.__store?.getState?.() + const tabId = state?.activeTabId ?? null + const ptyIds = tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : [] + const ptyId = ptyIds[0] ?? null + if (!ptyId) { + return null + } + await window.api.pty.kill(ptyId) + return ptyId + }), + rendererActionTimeoutMs + ) +} + +async function resetRendererProbe(page) { + // Why: probe drift is cumulative since installation; without a per-cycle + // reset, one startup/setup stall gets re-reported as a hang in every + // later cycle's rendererMaxDriftMs check. + await runWithTimeout( + 'renderer probe reset', + () => + page.evaluate(() => { + const probe = globalThis.__orcaApphangProbe?.probe + if (!probe) { + return + } + const now = performance.now() + probe.last = now + probe.lastTickAt = now + probe.startedAt = now + probe.maxDriftMs = 0 + probe.samples = 0 + }), + rendererActionTimeoutMs + ) +} + +async function runActivationCycle(page, target, args) { + await resetRendererProbe(page) + const marker = `ORCA_APPHANG_${target.index}_${Date.now()}` + const cycleStartedAt = Date.now() + const activationStartedAt = Date.now() + await clickWorktreeCard(page, target.id) + const activation = await waitForActiveWorktree(page, target.id) + const activationMs = Date.now() - activationStartedAt + const activePty = await waitForActivePty(page, target.id) + const diagnosticsBeforeOutput = await collectRendererDiagnostics(page) + const outputStartedAt = Date.now() + await sendOutputStress(page, activePty.ptyId, marker, args.outputLines) + const outputMs = Date.now() - outputStartedAt + const mainIpcMs = await pingMainIpc(page) + const rendererProbe = await readRendererProbe(page) + const diagnosticsAfterOutput = await collectRendererDiagnostics(page) + const elapsedMs = Date.now() - cycleStartedAt + const sample = { + index: target.index, + kind: target.kind, + worktreeId: target.id, + displayName: target.displayName, + elapsedMs, + activationMs, + outputMs, + activation, + ptyWaitMs: activePty.waitMs, + ptyId: activePty.ptyId, + tabId: activePty.tabId, + mainIpcMs, + rendererMaxDriftMs: rendererProbe?.maxDriftMs ?? null, + renderingDiagnostics: diagnosticsAfterOutput?.renderingDiagnostics ?? null, + webglIdentity: diagnosticsAfterOutput?.webglIdentity ?? null, + diagnosticsBeforeOutput, + diagnosticsAfterOutput + } + + const reproducedReasons = [] + if (activationMs > severeActivationMs) { + reproducedReasons.push(`worktree activation took ${activationMs}ms`) + } + if (activePty.waitMs > severePtyWaitMs) { + reproducedReasons.push(`PTY binding took ${activePty.waitMs}ms`) + } + if (mainIpcMs > severeMainIpcMs) { + reproducedReasons.push(`main IPC ping took ${mainIpcMs}ms`) + } + if ((rendererProbe?.maxDriftMs ?? 0) > severeRendererDriftMs) { + reproducedReasons.push(`renderer timer drift reached ${Math.round(rendererProbe.maxDriftMs)}ms`) + } + if (reproducedReasons.length > 0) { + throw new ReproductionObservedError(reproducedReasons.join('; '), sample) + } + return sample +} + +async function runDeadPtyReactivation(page, targets, args) { + const samples = [] + for (const target of targets) { + await clickWorktreeCard(page, target.id) + await waitForActiveWorktree(page, target.id) + await waitForActivePty(page, target.id) + const killedPtyId = await killActivePty(page) + samples.push({ worktreeId: target.id, killedPtyId }) + } + for (const target of targets) { + samples.push( + await runActivationCycle(page, { ...target, kind: `${target.kind}:dead-pty` }, args) + ) + } + return samples +} + +function selectTargets(setupResult, cycles) { + const gitWorktrees = setupResult.gitWorktrees + .filter((worktree) => worktree.path) + .sort((a, b) => Number(b.isMainWorktree) - Number(a.isMainWorktree)) + .slice(0, 5) + .map((worktree) => ({ ...worktree, kind: 'git-wsl' })) + const plainWorktrees = setupResult.plainWorktrees.map((worktree) => ({ + ...worktree, + kind: 'plain-folder-wsl' + })) + const baseTargets = [...gitWorktrees, ...plainWorktrees] + if (baseTargets.length === 0) { + throw new Error('No worktrees were discovered for the repro fixture.') + } + return Array.from({ length: cycles }, (_, index) => ({ + ...baseTargets[index % baseTargets.length], + index: index + 1 + })) +} + +export async function runGpuMode(gpuMode, args, fixture) { + const cdpPort = await pickFreePort() + const userDataDir = createGpuUserDataDirectory(gpuMode) + const launched = launchDevApp({ cdpPort, userDataDir }) + let browser = null + let page = null + const startedAt = Date.now() + const result = { + gpuMode, + reproduced: false, + reproductionReason: null, + harnessError: null, + startedAt, + elapsedMs: null, + cdpPort, + userDataDir, + appPid: launched.child.pid ?? null, + setup: null, + samples: [], + finalDiagnostics: null, + trace: null, + appLogSummary: null, + appLogsTail: null, + cleanupErrors: [] + } + + try { + const connected = await connectToApp(cdpPort) + browser = connected.browser + page = connected.page + await waitForStoreReady(page) + await installRendererProbe(page) + const identity = await runWithTimeout( + 'app identity', + () => page.evaluate(() => window.api.app.getIdentity?.()), + rendererActionTimeoutMs + ).catch(() => null) + console.log( + `[apphang-repro] connected gpu=${gpuMode} pid=${result.appPid} identity=${JSON.stringify(identity)}` + ) + result.setup = await setupAppFixture(page, fixture, gpuMode, args.sourceControl) + const targets = selectTargets(result.setup, args.cycles) + console.log( + `[apphang-repro] targets gpu=${gpuMode}: ${targets + .map((target) => `${target.kind}:${target.displayName}`) + .join(', ')}` + ) + for (const target of targets) { + console.log(`[apphang-repro] cycle=${target.index} gpu=${gpuMode} kind=${target.kind}`) + result.samples.push(await runActivationCycle(page, target, args)) + } + if (args.deadPtyReactivate) { + const uniqueTargets = [] + const seen = new Set() + for (const target of targets) { + if (!seen.has(target.id)) { + seen.add(target.id) + uniqueTargets.push(target) + } + } + console.log(`[apphang-repro] dead-pty-reactivation gpu=${gpuMode}`) + result.samples.push(...(await runDeadPtyReactivation(page, uniqueTargets, args))) + } + } catch (error) { + if (error instanceof ReproductionObservedError) { + result.reproduced = true + result.reproductionReason = error.message + result.samples.push(error.evidence) + } else { + // Why: setup/CDP/selector failures are inconclusive, not hang evidence. + // Conflating them with `reproduced` lets --expect=repro pass on a broken + // harness; callers treat harnessError as a hard failure instead. + result.harnessError = error instanceof Error ? error.stack || error.message : String(error) + } + } finally { + result.elapsedMs = Date.now() - startedAt + result.finalDiagnostics = await collectRendererDiagnostics(page) + result.trace = summarizeTrace(userDataDir) + result.appLogSummary = summarizeAppLogs(launched.logs) + result.appLogsTail = launched.logs.slice(-120) + if (browser) { + await browser.close().catch(() => undefined) + } + await stopDevApp(launched.child) + if (!args.keep) { + safeRemoveLocalDirectory(userDataDir, result.cleanupErrors) + } + } + return result +} diff --git a/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs b/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs new file mode 100644 index 00000000000..cd38f400d47 --- /dev/null +++ b/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs @@ -0,0 +1,135 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) + +function runWsl(distro, script, options = {}) { + return execFileSync('wsl.exe', ['-d', distro, '--', 'bash', '-se'], { + cwd: rootDir, + encoding: 'utf8', + input: script, + timeout: options.timeoutMs ?? 120_000, + stdio: ['pipe', 'pipe', 'pipe'] + }) +} + +export function listWslDistros() { + const output = execFileSync('wsl.exe', ['--list', '--quiet'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000 + }) + return output + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim().replace(/^\*\s*/, '')) + .filter((line) => line && !line.toLowerCase().startsWith('docker-desktop')) +} + +function linuxPathToWslUnc(distro, linuxPath) { + if (!linuxPath.startsWith('/')) { + throw new Error(`Expected absolute Linux path, got ${linuxPath}`) + } + return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` +} + +export function createWslFixture(distro) { + const script = String.raw` +set -euo pipefail +base="$(mktemp -d /tmp/orca-apphang-repro.XXXXXX)" +repo="$base/repo" +mkdir -p "$repo" +cd "$repo" +git init -q +git config user.email apphang-repro@test.local +git config user.name "AppHang Repro" +mkdir -p src docs +printf '# Orca Windows AppHang repro\n' > README.md +for n in $(seq 1 25); do + printf 'line %03d %s\n' "$n" "abcdefghijklmnopqrstuvwxyz0123456789" >> src/payload.txt +done +git add -A +git commit -q -m "Initial repro fixture" +for n in 1 2 3 4; do + git branch "repro-wt-$n" + git worktree add -q "$base/wt-$n" "repro-wt-$n" + mkdir -p "$base/wt-$n/docs" + printf 'worktree %s\n' "$n" > "$base/wt-$n/docs/wt.txt" +done +plain="$base/plain-folder" +mkdir -p "$plain/subdir" +printf 'plain folder for Orca AppHang repro\n' > "$plain/README.txt" +printf '%s\n' "$base" "$repo" "$base/wt-1" "$base/wt-2" "$base/wt-3" "$base/wt-4" "$plain" +` + const lines = runWsl(distro, script, { timeoutMs: 120_000 }).trim().split(/\r?\n/).filter(Boolean) + if (lines.length < 7) { + throw new Error(`WSL fixture creation returned unexpected output: ${JSON.stringify(lines)}`) + } + const [base, repo, ...rest] = lines + const plain = rest.at(-1) + const worktrees = rest.slice(0, -1) + return { + distro, + baseLinuxPath: base, + repoLinuxPath: repo, + plainLinuxPath: plain, + worktreeLinuxPaths: worktrees, + repoUncPath: linuxPathToWslUnc(distro, repo), + plainUncPath: linuxPathToWslUnc(distro, plain), + worktreeUncPaths: worktrees.map((entry) => linuxPathToWslUnc(distro, entry)) + } +} + +export function removeWslFixture(fixture) { + if (!fixture?.baseLinuxPath) { + return + } + const quoted = fixture.baseLinuxPath.replaceAll("'", "'\\''") + runWsl(fixture.distro, `rm -rf '${quoted}'`, { timeoutMs: 30_000 }) +} + +export function createCompletedOnboardingProfile(userDataDir) { + mkdirSync(userDataDir, { recursive: true }) + const profile = { + settings: { + telemetry: { + // Why: synthetic harness/benchmark activity must not send telemetry. + // Explicit false with existedBeforeTelemetryRelease=false also keeps + // the first-launch consent surface from blocking automation. + optedIn: false, + installId: '00000000-0000-4000-8000-000000000000', + existedBeforeTelemetryRelease: false + } + }, + onboarding: { + flowVersion: 4, + closedAt: 1, + outcome: 'completed', + lastCompletedStep: 5 + }, + ui: { + contextualToursAutoEligible: false, + contextualToursSeenIds: [ + 'workspace-board', + 'browser', + 'tasks', + 'automations', + 'workspace-creation' + ], + featureTipsSeenIds: [], + featureInteractions: {}, + projectOrderManualDefaultNoticeDismissed: true + } + } + writeFileSync(path.join(userDataDir, 'orca-data.json'), `${JSON.stringify(profile, null, 2)}\n`) +} + +export function safeRemoveLocalDirectory(dir, cleanupErrors) { + try { + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) + } catch (error) { + cleanupErrors.push(error instanceof Error ? error.message : String(error)) + } +} diff --git a/notes/windows-perf-progress.md b/notes/windows-perf-progress.md index 5cd3b89ff20..c31c10d243d 100644 --- a/notes/windows-perf-progress.md +++ b/notes/windows-perf-progress.md @@ -4,6 +4,69 @@ Goal: (1) significantly improve Windows startup time (~1 min cold start reported (2) fix OpenCode-driven UI freezes, (3) improve overall Windows performance. All changes must be proven with before/after benchmark numbers. +## Phase 2 (2026-07-02, branch Jinwoo-H/windows-performance-improvement) — terminal interaction latency + +Complaints: slow workspace switching, slow tab create/switch (terminal-related), occasional crashes. +Harness: `tools/benchmarks/terminal-perf-bench.mjs` (CDP-driven dev app, renderer-clock phase +timings; scenarios tab-create / tab-switch / workspace-switch; local git fixture). +Main-process spawn attribution: `ORCA_PTY_SPAWN_TIMING=1` → `[pty-spawn-timing]` lines +(pty.ts handler phases: preflight/auth/host_env/options/provider_spawn). + +Findings (baseline, this machine): +- Workspace switch: every hide disposed each pane's WebGL context; resume recreated it — + ~5ms macOS, 100-500ms/pane Windows ANGLE (the comment in terminal-visibility-resume.ts + admitted this). Premise (16-context budget) stale since #7064 raised budget to 128. +- Tab create: ~550ms steady state; main handler only ~115ms (host_env≈50ms, daemon + provider_spawn≈68ms). Remainder is renderer-side (xterm open + WebGL context for the new + pane + React mount). First-ever spawn paid +2.7s inside provider_spawn = daemon's first + ConPTY (native module + conpty.dll + OpenConsole + Defender), lazily on the user's first terminal. +- Tab switch: paint settle median 80-99ms; longtasks 64-151ms — every light tab resume runs + scheduleTerminalWebglAtlasRecovery: 3× (frame/120ms/500ms) global shared-atlas clear + + refresh of EVERY pane in EVERY manager. Parse-time recovery (pty-connection.ts + recoverWebglAtlasAfterParse / hiddenOutputNeedsAtlasRecoveryAfterParse) already covers + risky output including hidden. CAUTION: #7058 changed this area and was reverted (#7073) — + left as follow-up. +- LocalPtyProvider spawned without useConptyDll while the daemon path used it (legacy system + ConPTY corruption + perf differences on degraded-mode/fresh-local spawns). + +Fixes on this branch (PR #7080 merged in — WebGL release on dispose + stale pty:exit synthesis): +- A: WebGL context retention across hide/show (pane-webgl-context-retention.ts, LRU cap 32 — + sized for ~10-16 worktrees × ~2 terminals hot sets; suspend keeps live contexts; resume + repaints instead of recreating). Gated to Windows (getRendererAppPlatform() === 'win32'): + macOS/Linux context creation is cheap, so they keep dispose-on-hide and pay no retention + GPU-memory cost. Peer note: at least one comparable product raises the context budget to + 256 and retains unbounded with a session-wide DOM latch on loss; we bound + recover instead. +- B: useConptyDll for LocalPtyProvider spawns (local-pty-utils.ts) — parity with daemon. +- D: atlas recovery bursts skip suspended panes (scoped to visible); retained panes repaint + on resume (shared-atlas invariant preserved). +- F: daemon boots a throwaway `cmd.exe /c exit` ConPTY (windows-conpty-warmup.ts) so the + first user terminal doesn't pay the ~2.7s first-ConPTY cost. + +Follow-ups (documented, not in this branch): +- Gate/scope the light-tab-switch atlas burst (see #7058/#7073 history first). Residual + tab-switch cost besides the burst: debounced ResizeObserver re-fit can reflow scrollback + when column count changed while hidden. +- Renderer-side tab-create cost (~400ms): mount chain runs new Terminal() + 5 eager addons + + synchronous attachWebgl (pane-lifecycle.ts:108) before the spawn IPC (deferred one rAF, + pty-connection.ts:5158). Candidate: defer WebGL attach for brand-new panes. +- Cold-restore respawn fan-out: reconnectPersistedTerminals does NOT spawn; the fan-out is + Terminal.tsx mounting a TerminalPane per restored tab at once — each fires connectPanePty + → rAF-deferred spawn IPC (pty-connection.ts:5158) with no concurrency cap. Cap belongs at + that renderer connect layer, not in reconnectPersistedTerminals. +- First terminal opened immediately after launch also waits on the one-time daemon-init + barrier (pty:spawn awaits getLocalPtyStartupPromise, ipc/pty.ts:2518; measured + preflight=0 in the bench because hydration had finished first, but an early Ctrl+T pays + it). In-daemon Windows shell resolution (pwsh -Version probe, PowerShell exe-chain + existsSync/statSync scan) is uncached per spawn; the conpty warm-up spawns cmd.exe so it + does not warm PowerShell resolution. Note the warm-up and an early first spawn serialize + on the daemon's single thread — the 1255ms post-fix first-spawn number is mostly queueing + behind the in-flight warm-up, not unwarmed cost. +- node-pty ≥1.2.0-beta defers conpty connect (spawn returns pid=0 fast) — would stop spawn + storms serializing the daemon loop. + +Pre-existing Windows-only test failures (also on main, CI is ubuntu-only): 5 attribution-shim +PATH assertions in src/main/ipc/pty.test.ts (path-separator artifacts). + ## Status - [x] Benchmark harness for startup time (`tools/benchmarks/startup-time-bench.mjs`) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 07430b5fa3b..aeffb7aa2a5 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -8,6 +8,7 @@ */ import { startDaemon, type DaemonHandle } from './daemon-main' import { createPtySubprocess } from './pty-subprocess' +import { warmWindowsConptyOnce } from './windows-conpty-warmup' import { warmPwshAvailabilityCache } from '../pwsh' export function parseArgs(argv: string[]): { socketPath: string; tokenPath: string } { @@ -84,6 +85,8 @@ async function main(): Promise { if (process.send) { process.send({ type: 'ready' }) } + + warmWindowsConptyOnce() } // Only auto-run when executed directly (not imported for testing) diff --git a/src/main/daemon/windows-conpty-warmup.test.ts b/src/main/daemon/windows-conpty-warmup.test.ts new file mode 100644 index 00000000000..1ab19b0c97e --- /dev/null +++ b/src/main/daemon/windows-conpty-warmup.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as pty from 'node-pty' +import { warmWindowsConptyOnce } from './windows-conpty-warmup' + +function setPlatform(platform: NodeJS.Platform): () => void { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + return () => Object.defineProperty(process, 'platform', { configurable: true, value: original }) +} + +function flushImmediates(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} + +let restorePlatform: (() => void) | null = null +afterEach(() => { + restorePlatform?.() + restorePlatform = null + vi.restoreAllMocks() +}) + +function makeFakePty(): { proc: pty.IPty; fireExit: () => void } { + let exitListener: (() => void) | null = null + const proc = { + pid: 4321, + kill: vi.fn(), + onExit: vi.fn((listener: () => void) => { + exitListener = listener + return { dispose: () => undefined } + }) + } as unknown as pty.IPty + return { proc, fireExit: () => exitListener?.() } +} + +describe('warmWindowsConptyOnce', () => { + it('is a no-op off Windows', async () => { + restorePlatform = setPlatform('darwin') + const spawnPty = vi.fn() as unknown as typeof pty.spawn + + warmWindowsConptyOnce(spawnPty) + await flushImmediates() + + expect(spawnPty).not.toHaveBeenCalled() + }) + + it('spawns a short-lived cmd.exe with the bundled ConPTY on Windows', async () => { + restorePlatform = setPlatform('win32') + const { proc, fireExit } = makeFakePty() + const spawnPty = vi.fn(() => proc) as unknown as typeof pty.spawn + + warmWindowsConptyOnce(spawnPty) + await flushImmediates() + + expect(spawnPty).toHaveBeenCalledTimes(1) + const [file, args, options] = vi.mocked(spawnPty).mock.calls[0] + expect(String(file).toLowerCase()).toContain('cmd') + expect(args).toEqual(['/c', 'exit']) + expect(options).toMatchObject({ useConptyDll: true, cols: 2, rows: 1 }) + + // A clean exit must not leave the kill timer to fire later. + fireExit() + expect(proc.kill).not.toHaveBeenCalled() + }) + + it('kills the warm-up shell if it never exits', async () => { + restorePlatform = setPlatform('win32') + vi.useFakeTimers() + try { + const { proc } = makeFakePty() + const spawnPty = vi.fn(() => proc) as unknown as typeof pty.spawn + + warmWindowsConptyOnce(spawnPty) + await vi.runOnlyPendingTimersAsync() + vi.advanceTimersByTime(10_000) + + expect(proc.kill).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('swallows spawn failures', async () => { + restorePlatform = setPlatform('win32') + const spawnPty = vi.fn(() => { + throw new Error('conpty unavailable') + }) as unknown as typeof pty.spawn + + expect(() => warmWindowsConptyOnce(spawnPty)).not.toThrow() + await flushImmediates() + }) +}) diff --git a/src/main/daemon/windows-conpty-warmup.ts b/src/main/daemon/windows-conpty-warmup.ts new file mode 100644 index 00000000000..aea1c50b2db --- /dev/null +++ b/src/main/daemon/windows-conpty-warmup.ts @@ -0,0 +1,45 @@ +import os from 'node:os' +import * as pty from 'node-pty' + +const WARMUP_KILL_TIMEOUT_MS = 10_000 + +/** + * Pays the one-time cost of the first ConPTY spawn (conpty native module + * load, bundled conpty.dll + OpenConsole.exe first launch, Defender scans of + * those binaries) at daemon boot instead of on the user's first terminal. + * Measured ~2.7s on a Windows dev profile for the first spawn vs ~70ms after. + */ +export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = pty.spawn): void { + if (process.platform !== 'win32') { + return + } + // Why: setImmediate keeps the ready/handshake path ahead of the warm-up; a + // real spawn arriving first simply does the warming itself. + setImmediate(() => { + try { + const proc = spawnPty(process.env.COMSPEC || 'cmd.exe', ['/c', 'exit'], { + name: 'xterm-256color', + cols: 2, + rows: 1, + cwd: os.homedir(), + env: process.env as Record, + // Match real terminal spawns so the bundled ConPTY binaries are the + // ones warmed, not the legacy system ConPTY. + useConptyDll: true + }) + const killTimer = setTimeout(() => { + try { + proc.kill() + } catch { + /* best-effort cleanup of a stuck warm-up shell */ + } + }, WARMUP_KILL_TIMEOUT_MS) + killTimer.unref?.() + proc.onExit(() => { + clearTimeout(killTimer) + }) + } catch { + /* warm-up is best-effort; real spawns surface their own errors */ + } + }) +} diff --git a/src/main/ipc/pty-spawn-timing.ts b/src/main/ipc/pty-spawn-timing.ts new file mode 100644 index 00000000000..0ab0e12c800 --- /dev/null +++ b/src/main/ipc/pty-spawn-timing.ts @@ -0,0 +1,41 @@ +// Why: pty:spawn latency has four very different suspects (startup barrier, +// Claude auth prep, buildPtyHostEnv filesystem work, provider/daemon spawn). +// A single opt-in log line per spawn lets benchmarks attribute the cost +// without a tracing dependency. Enabled via ORCA_PTY_SPAWN_TIMING=1. + +export type PtySpawnTiming = { + mark(phase: string): void + log(id: string, extra?: Record): void +} + +const noopTiming: PtySpawnTiming = { + mark: () => undefined, + log: () => undefined +} + +export function createPtySpawnTiming(): PtySpawnTiming { + const flag = process.env.ORCA_PTY_SPAWN_TIMING + if (!flag || flag === '0' || flag.toLowerCase() === 'false') { + return noopTiming + } + const startedAt = Date.now() + let lastAt = startedAt + const phases: string[] = [] + return { + mark(phase: string): void { + const now = Date.now() + phases.push(`${phase}=${now - lastAt}ms`) + lastAt = now + }, + log(id: string, extra?: Record): void { + const extras = extra + ? ` ${Object.entries(extra) + .map(([key, value]) => `${key}=${value}`) + .join(' ')}` + : '' + console.log( + `[pty-spawn-timing] id=${id} total=${Date.now() - startedAt}ms ${phases.join(' ')}${extras}` + ) + } + } +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 0afc0c3e4e0..c12f8466d3f 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -2331,7 +2331,9 @@ describe('registerPtyHandlers', () => { } expect(controller.kill('remote-pty')).toBe(true) - await Promise.resolve() + // Why: kill's shutdown now runs through the exit-detection wrapper, + // which adds async hops; a single microtask flush is no longer enough. + await new Promise((resolve) => setImmediate(resolve)) expect(shutdown).toHaveBeenCalledWith('remote-pty', { immediate: false }) expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( @@ -2342,6 +2344,113 @@ describe('registerPtyHandlers', () => { expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1) }) + it('controller kill does not duplicate exits when the provider emits exit during shutdown', async () => { + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const shutdown = vi.fn(async (id: string) => { + for (const listener of exitListeners) { + listener({ id, code: 0 }) + } + }) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + kill: (ptyId: string) => boolean + } + + expect(controller.kill('local-pty')).toBe(true) + await Promise.resolve() + await Promise.resolve() + + expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0) + expect( + mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') + ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]]) + }) + + it('controller stopAndWait skips the synthetic exit when the provider emitted one', async () => { + vi.useFakeTimers() + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const shutdown = vi.fn(async (id: string) => { + for (const listener of exitListeners) { + listener({ id, code: 0 }) + } + }) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise + } + + const stopPromise = controller.stopAndWait('local-pty') + await vi.advanceTimersByTimeAsync(1_200) + await expect(stopPromise).resolves.toBe(true) + + expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0) + expect( + mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') + ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]]) + }) + it('passes keepHistory through runtime controller stopAndWait', async () => { vi.useFakeTimers() const shutdown = vi.fn(async () => undefined) @@ -2566,7 +2675,7 @@ describe('registerPtyHandlers', () => { } expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true) - await Promise.resolve() + await new Promise((resolve) => setImmediate(resolve)) expect(shutdown).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', { immediate: false }) expect(localShutdown).not.toHaveBeenCalled() @@ -2701,8 +2810,7 @@ describe('registerPtyHandlers', () => { try { expect(controller.kill('remote-pty')).toBe(true) - await Promise.resolve() - await Promise.resolve() + await new Promise((resolve) => setImmediate(resolve)) } finally { warnSpy.mockRestore() deletePtyOwnership('remote-pty') @@ -2911,6 +3019,104 @@ describe('registerPtyHandlers', () => { keepHistory: true }) expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { + id: 'local-pty', + code: -1 + }) + }) + + it('does not synthesize a duplicate renderer exit when kill emits provider exit', async () => { + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const shutdown = vi.fn(async (id: string) => { + for (const listener of exitListeners) { + listener({ id, code: 0 }) + } + }) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + + await handlers.get('pty:kill')!(null, { id: 'local-pty' }) + + expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0) + expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual( + [['pty:exit', { id: 'local-pty', code: 0 }]] + ) + }) + + it('ignores a late provider exit after synthesizing kill exit', async () => { + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(async () => undefined), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + + await handlers.get('pty:kill')!(null, { id: 'local-pty' }) + for (const listener of exitListeners) { + listener({ id: 'local-pty', code: 0 }) + } + + expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1) + expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual( + [['pty:exit', { id: 'local-pty', code: -1 }]] + ) }) it('waits for the desktop startup barrier before renderer local spawns resolve the provider', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 9f654ea1e1f..af29900b379 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -39,6 +39,7 @@ import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { SSH_SESSION_EXPIRED_ERROR, isSshPtyNotFoundError } from '../providers/ssh-pty-provider' import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' +import { createPtySpawnTiming } from './pty-spawn-timing' import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' @@ -111,6 +112,7 @@ type FreshLocalFallbackProvider = IPtyProvider & { routesFreshSpawnsToLocalProvider?: true } const sshProviders = new Map() +const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 // Why: PTY IDs are assigned at spawn time with a connectionId, but subsequent // write/resize/kill calls only carry the PTY ID. This map lets us route // post-spawn operations to the correct provider without the renderer needing @@ -1656,6 +1658,82 @@ export function registerPtyHandlers( flushTimer = null } + const syntheticKillExitPtyIds = new Map() + + function rememberSyntheticKillExit(id: string): void { + const existing = syntheticKillExitPtyIds.get(id) + if (existing) { + clearTimeout(existing) + } + // Why: some providers can report the real exit after kill has already + // completed; skip only that late duplicate, not a future reused id forever. + const cleanupTimer = setTimeout(() => { + syntheticKillExitPtyIds.delete(id) + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + cleanupTimer.unref?.() + syntheticKillExitPtyIds.set(id, cleanupTimer) + } + + function consumeSyntheticKillExit(id: string): boolean { + const cleanupTimer = syntheticKillExitPtyIds.get(id) + if (!cleanupTimer) { + return false + } + clearTimeout(cleanupTimer) + syntheticKillExitPtyIds.delete(id) + return true + } + + function sendPtyExitToRenderer(payload: { id: string; code: number }): void { + if (mainWindow.isDestroyed()) { + return + } + // Why: flush any batched data for this PTY before sending the exit event, + // otherwise the last <=8ms of output is silently lost because the renderer + // tears down the terminal on pty:exit before the batch timer fires. + const remaining = pendingData.get(payload.id) + if (remaining) { + sendPtyDataToRenderer( + payload.id, + makePtyDataPayload( + payload.id, + remaining.data, + remaining.startSeq, + remaining.containsBackgroundOutput + ) + ) + pendingData.delete(payload.id) + } + lastInputAtByPty.delete(payload.id) + interactiveOutputCharsByPty.delete(payload.id) + rendererInFlightTotalChars = Math.max( + 0, + rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0) + ) + rendererInFlightCharsByPty.delete(payload.id) + recordPtyRendererDeliveryPressure() + mainWindow.webContents.send('pty:exit', payload) + } + + async function shutdownProviderAndDetectExit( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean } + ): Promise { + let providerExitObserved = false + const unsubscribe = provider.onExit((payload) => { + if (payload.id === id) { + providerExitObserved = true + } + }) + try { + await provider.shutdown(id, opts) + } finally { + unsubscribe() + } + return providerExitObserved + } + // Why: extracted so the "Restart daemon" flow can rebind against the fresh // adapter after replaceDaemonProvider runs. Both the startup registration // and the post-restart rebind go through the same code path — no risk of @@ -1745,39 +1823,16 @@ export function registerPtyHandlers( } }) localExitUnsub = localProvider.onExit((payload) => { + if (consumeSyntheticKillExit(payload.id)) { + return + } if (!isLocalProvider) { clearProviderPtyState(payload.id) ptyOwnership.delete(payload.id) markClaudePtyExited(payload.id) runtime?.onPtyExit(payload.id, payload.code) } - if (!mainWindow.isDestroyed()) { - // Why: flush any batched data for this PTY before sending the exit event, - // otherwise the last ≤8ms of output is silently lost because the renderer - // tears down the terminal on pty:exit before the batch timer fires. - const remaining = pendingData.get(payload.id) - if (remaining) { - sendPtyDataToRenderer( - payload.id, - makePtyDataPayload( - payload.id, - remaining.data, - remaining.startSeq, - remaining.containsBackgroundOutput - ) - ) - pendingData.delete(payload.id) - } - lastInputAtByPty.delete(payload.id) - interactiveOutputCharsByPty.delete(payload.id) - rendererInFlightTotalChars = Math.max( - 0, - rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0) - ) - rendererInFlightCharsByPty.delete(payload.id) - recordPtyRendererDeliveryPressure() - mainWindow.webContents.send('pty:exit', payload) - } + sendPtyExitToRenderer(payload) }) } @@ -2240,6 +2295,8 @@ export function registerPtyHandlers( // not revive a terminal the user explicitly closed. finishPtyShutdown(ptyId, connectionId, store) runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) return true } return false @@ -2247,16 +2304,25 @@ export function registerPtyHandlers( // Why: shutdown() is async but the PtyController interface is sync. Defer // cleanup until shutdown resolves so transient SSH/daemon failures don't // hide a still-running remote process or local daemon session. - void provider - .shutdown(ptyId, { immediate: false }) - .then(() => { + // + // Same synthetic-exit contract as the renderer pty:kill handler: when the + // provider emitted its own exit during shutdown, the exit listener already + // delivered runtime + renderer exits — synthesizing again would double-fire. + void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }) + .then((providerExitObserved) => { finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1) + if (!providerExitObserved) { + runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) + } }) .catch((err) => { if (isPtyAlreadyGoneError(err)) { finishPtyShutdown(ptyId, connectionId, store) runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) return } console.warn( @@ -2283,12 +2349,15 @@ export function registerPtyHandlers( // await, but the relay lease must still be tombstoned. finishPtyShutdown(ptyId, connectionId, store) runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) return true } return false } + let providerExitObserved = false try { - await provider.shutdown(ptyId, { + providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, { immediate: true, keepHistory: opts?.keepHistory ?? false }) @@ -2313,7 +2382,11 @@ export function registerPtyHandlers( return false } finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1) + if (!providerExitObserved) { + runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) + } return true }, getForegroundProcess: async (ptyId) => { @@ -2461,11 +2534,13 @@ export function registerPtyHandlers( } } ) => { + const spawnTiming = createPtySpawnTiming() const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { await startupPromise } await assertFolderWorkspacePtyPathUsable(args.worktreeId) + spawnTiming.mark('preflight') const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -2488,6 +2563,7 @@ export function registerPtyHandlers( ) const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null + spawnTiming.mark('auth') if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } @@ -2713,6 +2789,7 @@ export function registerPtyHandlers( throw err } } + spawnTiming.mark('host_env') const spawnEnv = preAllocatedHandle ? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle } : env @@ -2807,7 +2884,9 @@ export function registerPtyHandlers( startupTerminalColorQueryReplyColors ) } + spawnTiming.mark('options') result = await provider.spawn(spawnOptions) + spawnTiming.mark('provider_spawn') } catch (err) { const rawMessage = err instanceof Error ? err.message : String(err) const spawnError = normalizeNodePtySpawnError(err) @@ -2868,6 +2947,10 @@ export function registerPtyHandlers( trustedTerminalHandleEnv.delete(preAllocatedHandle) } } + spawnTiming.log(result.id, { + daemon: isDaemonHostSpawn, + reattach: result.isReattach ?? false + }) ptyOwnership.set(result.id, args.connectionId ?? null) if (startupTerminalColorQueryReplyColors) { if (result.isReattach) { @@ -3396,10 +3479,14 @@ export function registerPtyHandlers( // before ownership is rebuilt. Tombstone instead of falling back local. finishPtyShutdown(args.id, connectionId, store) runtime?.onPtyExit(args.id, -1) + rememberSyntheticKillExit(args.id) + sendPtyExitToRenderer({ id: args.id, code: -1 }) return } + const shutdownProvider = provider ?? getProviderForPty(args.id) + let providerExitObserved = false try { - await (provider ?? getProviderForPty(args.id)).shutdown(args.id, { + providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, { immediate: true, keepHistory: args.keepHistory ?? false }) @@ -3412,11 +3499,14 @@ export function registerPtyHandlers( } /* session already dead — cleanup below handles the rest */ } - // Why: onExit clears provider state for LocalPtyProvider, but remote SSH - // and daemon shutdown paths do not emit onExit through the local provider's - // listener. Explicit cleanup is idempotent and covers already-dead PTYs. + // Why: some shutdown paths do not emit onExit through the provider listener. + // Explicit cleanup is idempotent and covers already-dead PTYs. finishPtyShutdown(args.id, connectionId, store) - runtime?.onPtyExit(args.id, -1) + if (!providerExitObserved) { + runtime?.onPtyExit(args.id, -1) + rememberSyntheticKillExit(args.id) + sendPtyExitToRenderer({ id: args.id, code: -1 }) + } }) ipcMain.handle( diff --git a/src/main/providers/local-pty-utils-windows-fallback.test.ts b/src/main/providers/local-pty-utils-windows-fallback.test.ts index 48b35cd27c0..0de16d03f17 100644 --- a/src/main/providers/local-pty-utils-windows-fallback.test.ts +++ b/src/main/providers/local-pty-utils-windows-fallback.test.ts @@ -76,13 +76,13 @@ describe('spawnShellWithFallback on Windows', () => { 1, PWSH7, attempts[0].shellArgs, - expect.objectContaining({ cwd: 'C:\\repo' }) + expect.objectContaining({ cwd: 'C:\\repo', useConptyDll: true }) ) expect(ptySpawn).toHaveBeenNthCalledWith( 2, WINDOWS_POWERSHELL, attempts[1].shellArgs, - expect.objectContaining({ cwd: 'C:\\repo' }) + expect.objectContaining({ cwd: 'C:\\repo', useConptyDll: true }) ) }) diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 0ad6effc9c9..c8eb87c249d 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -160,6 +160,14 @@ export type ShellSpawnResult = { * executables with per-shell args, so when the primary fails we retry with the * next safe shell instead of leaving the user with no terminal. */ +// Why: match the daemon spawn path (pty-subprocess.ts) — the bundled ConPTY +// has the modern wrap-marker behavior xterm expects; legacy system ConPTY can +// corrupt full-width TUI rows in scrollback. Without this, degraded-mode and +// fresh-local spawns silently behave differently from daemon terminals. +function windowsConptyDllOptions(): { useConptyDll: true } | Record { + return process.platform === 'win32' ? { useConptyDll: true } : {} +} + function spawnWindowsFallbackChain( params: ShellSpawnParams, primaryError: string @@ -174,7 +182,8 @@ function spawnWindowsFallbackChain( cols, rows, cwd: attempt.effectiveCwd, - env + env, + ...windowsConptyDllOptions() }) console.warn( `[pty] Primary shell "${params.shellPath}" failed (${primaryError}), fell back to "${attempt.shellPath}"` @@ -217,7 +226,14 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu if (!primaryError) { try { return { - process: ptySpawn(shellPath, shellArgs, { name: termName, cols, rows, cwd, env }), + process: ptySpawn(shellPath, shellArgs, { + name: termName, + cols, + rows, + cwd, + env, + ...windowsConptyDllOptions() + }), shellPath } } catch (err) { diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 7756e7904c6..490b09bb065 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -237,11 +237,7 @@ export function disposePane( } catch { /* ignore */ } - try { - pane.webglAddon?.dispose() - } catch { - /* ignore */ - } + disposeWebgl(pane) try { pane.searchAddon.dispose() } catch { diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 61ff5a7d42d..0a30f50f118 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -168,6 +168,11 @@ export class PaneManager { refreshAllPanes(): void { for (const pane of this.panes.values()) { + // Why: suspended panes are invisible and repaint on rendering resume; + // recovery repaints must not scale with hidden-workspace pane count. + if (pane.webglAttachmentDeferred) { + continue + } try { if (pane.terminal.rows > 0) { pane.terminal.refresh(0, pane.terminal.rows - 1) diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts index 8ad22f07eca..c79598ff0eb 100644 --- a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts +++ b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts @@ -7,6 +7,11 @@ import { markComplexScriptOutput, resetWebglTextureAtlas } from './pane-webgl-renderer' +import { + retainSuspendedWebglPane, + shouldRetainSuspendedWebglContexts, + unretainWebglPane +} from './pane-webgl-context-retention' import { reattachWebglIfNeeded } from './pane-webgl-reattach' export function setPaneGpuRenderingState( @@ -43,9 +48,20 @@ export function markPaneComplexScriptOutput( } export function suspendPaneRendering(panes: Iterable): void { + const retainContexts = shouldRetainSuspendedWebglContexts() for (const pane of panes) { + // Why: deferred blocks NEW context creation while hidden; on Windows, + // live contexts are retained (not disposed) so the return switch repaints + // instantly instead of paying ANGLE context re-creation. The LRU cap + // bounds how many hidden panes may keep one. pane.webglAttachmentDeferred = true - disposeWebgl(pane) + if (!retainContexts) { + disposeWebgl(pane) + continue + } + for (const evicted of retainSuspendedWebglPane(pane)) { + disposeWebgl(evicted) + } } } @@ -55,8 +71,23 @@ export function resumePaneRendering(panes: Iterable): void // loss, and bounding retries to resume events cannot loop on live loss. clearTerminalWebglAttachBackoff() for (const pane of panes) { + unretainWebglPane(pane) pane.webglAttachmentDeferred = false pane.webglDisabledAfterContextLoss = false + if (pane.webglAddon) { + // Why: recovery bursts skip suspended panes, so the shared glyph atlas + // may have been cleared/rebuilt while this pane sat hidden with its + // retained context. Repaint from the buffer so stale glyph coordinates + // never reach the screen. + try { + if (pane.terminal.rows > 0) { + pane.terminal.refresh(0, pane.terminal.rows - 1) + } + } catch { + /* ignore — pane may be tearing down during resume */ + } + continue + } reattachWebglIfNeeded(pane) } } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.test.ts new file mode 100644 index 00000000000..eb4c14bc6d5 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import { + clearRetainedWebglPanes, + RETAINED_WEBGL_PANE_CAP, + retainedWebglPaneCount +} from './pane-webgl-context-retention' +import { resumePaneRendering, suspendPaneRendering } from './pane-rendering-control' +import { + attachWebgl, + disposeWebgl, + resetTerminalWebglSuggestion, + resetWebglTextureAtlas +} from './pane-webgl-renderer' + +let nextPaneId = 1 + +function createPane(): ManagedPaneInternal { + const leafId = '11111111-1111-4111-8111-111111111111' as never + return { + id: nextPaneId++, + leafId, + stablePaneId: leafId, + terminal: { + cols: 80, + rows: 24, + refresh: vi.fn(), + loadAddon: vi.fn() + } as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'on', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: null, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + pendingWebglRefreshRafId: null, + fitAddon: { + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })), + fit: vi.fn() + } as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +function createAttachedPane(): ManagedPaneInternal { + const pane = createPane() + attachWebgl(pane) + expect(pane.webglAddon).not.toBeNull() + return pane +} + +function fireContextLoss(pane: ManagedPaneInternal): void { + const addon = pane.webglAddon as unknown as { _onContextLoss: { fire: () => void } } + addon._onContextLoss.fire() +} + +function stubCommonGlobals(userAgent: string): void { + vi.stubGlobal('navigator', { userAgent }) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) +} + +describe('terminal WebGL context retention across hide/show (Windows)', () => { + beforeEach(() => { + resetTerminalWebglSuggestion() + clearRetainedWebglPanes() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + stubCommonGlobals('Mozilla/5.0 (Windows NT 10.0; Win64; x64)') + }) + + afterEach(() => { + clearRetainedWebglPanes() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('keeps the live WebGL addon when rendering is suspended', () => { + const pane = createAttachedPane() + const addon = pane.webglAddon + + suspendPaneRendering([pane]) + + expect(pane.webglAttachmentDeferred).toBe(true) + expect(pane.webglAddon).toBe(addon) + expect(retainedWebglPaneCount()).toBe(1) + }) + + it('does not create a new context when resuming a retained pane', () => { + const pane = createAttachedPane() + const addon = pane.webglAddon + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1) + + suspendPaneRendering([pane]) + resumePaneRendering([pane]) + + expect(pane.webglAddon).toBe(addon) + expect(pane.webglAttachmentDeferred).toBe(false) + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1) + expect(retainedWebglPaneCount()).toBe(0) + }) + + it('still blocks new context creation while suspended', () => { + const pane = createPane() + + suspendPaneRendering([pane]) + attachWebgl(pane) + + expect(pane.webglAddon).toBeNull() + expect(pane.terminal.loadAddon).not.toHaveBeenCalled() + }) + + it('evicts and disposes the least-recently-suspended pane past the cap', () => { + const panes = Array.from({ length: RETAINED_WEBGL_PANE_CAP + 2 }, () => createAttachedPane()) + + suspendPaneRendering(panes) + + expect(retainedWebglPaneCount()).toBe(RETAINED_WEBGL_PANE_CAP) + expect(panes[0].webglAddon).toBeNull() + expect(panes[1].webglAddon).toBeNull() + expect(panes[2].webglAddon).not.toBeNull() + expect(panes.at(-1)?.webglAddon).not.toBeNull() + }) + + it('re-attaches an evicted pane on resume', () => { + const panes = Array.from({ length: RETAINED_WEBGL_PANE_CAP + 1 }, () => createAttachedPane()) + suspendPaneRendering(panes) + expect(panes[0].webglAddon).toBeNull() + + resumePaneRendering([panes[0]]) + + expect(panes[0].webglAddon).not.toBeNull() + expect(panes[0].terminal.loadAddon).toHaveBeenCalledTimes(2) + }) + + it('drops the retention entry when a suspended pane is disposed', () => { + const pane = createAttachedPane() + suspendPaneRendering([pane]) + expect(retainedWebglPaneCount()).toBe(1) + + disposeWebgl(pane) + + expect(retainedWebglPaneCount()).toBe(0) + expect(pane.webglAddon).toBeNull() + }) + + it('repaints a retained pane on resume so a cleared shared atlas cannot leave stale glyphs', () => { + const pane = createAttachedPane() + suspendPaneRendering([pane]) + vi.mocked(pane.terminal.refresh).mockClear() + + resumePaneRendering([pane]) + + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, pane.terminal.rows - 1) + }) + + it('skips suspended panes during atlas recovery resets', () => { + const pane = createAttachedPane() + const clearTextureAtlas = vi.fn() + ;(pane.webglAddon as unknown as { clearTextureAtlas: () => void }).clearTextureAtlas = + clearTextureAtlas + suspendPaneRendering([pane]) + vi.mocked(pane.terminal.refresh).mockClear() + + resetWebglTextureAtlas(pane) + + expect(clearTextureAtlas).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + }) + + it('drops the retention entry on context loss while hidden and recovers on resume', () => { + const pane = createAttachedPane() + suspendPaneRendering([pane]) + + fireContextLoss(pane) + + expect(pane.webglAddon).toBeNull() + expect(pane.webglDisabledAfterContextLoss).toBe(true) + expect(retainedWebglPaneCount()).toBe(0) + + resumePaneRendering([pane]) + + expect(pane.webglDisabledAfterContextLoss).toBe(false) + expect(pane.webglAddon).not.toBeNull() + }) +}) + +describe('terminal WebGL context retention is Windows-only', () => { + beforeEach(() => { + resetTerminalWebglSuggestion() + clearRetainedWebglPanes() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + stubCommonGlobals('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') + }) + + afterEach(() => { + clearRetainedWebglPanes() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('keeps the dispose-on-hide behavior off Windows', () => { + const pane = createAttachedPane() + + suspendPaneRendering([pane]) + + expect(pane.webglAttachmentDeferred).toBe(true) + expect(pane.webglAddon).toBeNull() + expect(retainedWebglPaneCount()).toBe(0) + }) + + it('re-attaches on resume off Windows as before', () => { + const pane = createAttachedPane() + suspendPaneRendering([pane]) + + resumePaneRendering([pane]) + + expect(pane.webglAddon).not.toBeNull() + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.ts b/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.ts new file mode 100644 index 00000000000..282d57fc5e7 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.ts @@ -0,0 +1,56 @@ +import { getRendererAppPlatform } from '@/lib/renderer-app-platform' +import type { ManagedPaneInternal } from './pane-manager-types' + +// Why: workspace switches used to dispose every hidden pane's WebGL context +// and recreate it on return — ~5ms on macOS but 100-500ms per pane on Windows +// (ANGLE → D3D11), paid synchronously on the switch path. With the app-level +// context budget raised to 128 (#7064), hidden panes can keep their contexts; +// this LRU cap only bounds hidden-pane GPU memory. Sized for the reported hot +// set (~10-16 worktrees × ~2 terminals) while staying far under the budget. +export const RETAINED_WEBGL_PANE_CAP = 32 + +/** Windows-only: context re-creation is cheap on macOS/Linux GL, so hidden + * panes there keep the long-proven dispose-on-hide behavior instead of + * paying retention's GPU-memory cost for no perceptible switch latency win. */ +export function shouldRetainSuspendedWebglContexts(): boolean { + return getRendererAppPlatform() === 'win32' +} + +// Insertion order doubles as LRU order: re-retaining deletes + re-adds. +const retainedPanes = new Set() + +/** + * Registers a suspended pane's live WebGL context for retention across + * hide/show. Returns panes evicted by the cap — the caller disposes them + * (dependency points that way to avoid a cycle with pane-webgl-renderer). + */ +export function retainSuspendedWebglPane(pane: ManagedPaneInternal): ManagedPaneInternal[] { + if (!pane.webglAddon) { + return [] + } + retainedPanes.delete(pane) + retainedPanes.add(pane) + const evicted: ManagedPaneInternal[] = [] + while (retainedPanes.size > RETAINED_WEBGL_PANE_CAP) { + const oldest = retainedPanes.values().next().value + if (!oldest) { + break + } + retainedPanes.delete(oldest) + evicted.push(oldest) + } + return evicted +} + +export function unretainWebglPane(pane: ManagedPaneInternal): void { + retainedPanes.delete(pane) +} + +export function retainedWebglPaneCount(): number { + return retainedPanes.size +} + +/** Drops all retention entries without disposing anything (test isolation). */ +export function clearRetainedWebglPanes(): void { + retainedPanes.clear() +} diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts index 6cff7287564..846b2779abc 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts @@ -65,6 +65,30 @@ describe('pane WebGL refresh lifecycle', () => { expect(pane.pendingWebglRefreshRafId).toBe(29) }) + it('actively releases the xterm WebGL context before disposing the addon', () => { + const loseContext = vi.fn() + const canvas = { width: 120, height: 40 } + const dispose = vi.fn() + const pane = createPane({ + webglAddon: { + dispose, + _renderer: { + _gl: { + getExtension: vi.fn(() => ({ loseContext })) + }, + _canvas: canvas + } + } as never + }) + + disposeWebgl(pane) + + expect(loseContext).toHaveBeenCalledTimes(1) + expect(dispose).toHaveBeenCalledTimes(1) + expect(canvas).toEqual({ width: 0, height: 0 }) + expect(pane.webglAddon).toBeNull() + }) + it('cancels a pending WebGL refresh when the pane is disposed', () => { const cancelAnimationFrame = vi.fn() vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index cca7cc9dd17..1b129c2ae9a 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -1,5 +1,6 @@ import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' +import { unretainWebglPane } from './pane-webgl-context-retention' import { getTerminalWebglAutoDecision, resetTerminalWebglAutoDecision @@ -14,6 +15,17 @@ let suggestedRendererType: 'dom' | undefined // until the next recovery boundary (rendering resume or GPU-setting change). let webglAttachFailedSinceRecovery = false +type ReleasableWebglContext = { + getExtension(name: 'WEBGL_lose_context'): WEBGL_lose_context | null +} + +type XtermWebglAddonInternals = { + _renderer?: { + _gl?: ReleasableWebglContext + _canvas?: HTMLCanvasElement + } +} + export function resetTerminalWebglSuggestion(): void { // Why: toggling GPU settings should let "auto" retry WebGL after an earlier // attach failure suggested DOM rendering for this app session. @@ -61,9 +73,14 @@ export function disposeWebgl( options?: { refreshDimensions?: boolean } ): void { cancelPendingWebglRefresh(pane) + // Why: every dispose path (pane close, context loss, GPU-setting off) must + // drop the retention entry, or the LRU would later evict-dispose a pane + // whose addon was already gone. + unretainWebglPane(pane) if (!pane.webglAddon) { return } + releaseXtermWebglContext(pane.webglAddon) try { pane.webglAddon.dispose() } catch { @@ -85,12 +102,32 @@ export function disposeWebgl( } } +function releaseXtermWebglContext(webglAddon: ManagedPaneInternal['webglAddon']): void { + try { + // Why: xterm removes the canvas on dispose, but Windows/ANGLE can keep the + // driver context alive long enough for rapid terminal activation to hit + // Chromium's active WebGL context budget (#6874). + const renderer = (webglAddon as unknown as XtermWebglAddonInternals | null)?._renderer + renderer?._gl?.getExtension('WEBGL_lose_context')?.loseContext() + if (renderer?._canvas) { + renderer._canvas.width = 0 + renderer._canvas.height = 0 + } + } catch { + /* ignore - WebGL teardown must not block fallback to the DOM renderer */ + } +} + export function markComplexScriptOutput(pane: ManagedPaneInternal): void { pane.hasComplexScriptOutput = true } export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { - if (pane.webglDisabledAfterContextLoss) { + // Why: suspended panes keep retained contexts but are invisible; their + // rebuild is deferred to resumePaneRendering. Clearing + repainting them + // here would make every recovery burst scale with total pane count across + // all workspaces instead of visible panes. + if (pane.webglDisabledAfterContextLoss || pane.webglAttachmentDeferred) { return } try { diff --git a/tools/benchmarks/results/terminal-perf-baseline-2026-07-02T07-23-46-933Z.json b/tools/benchmarks/results/terminal-perf-baseline-2026-07-02T07-23-46-933Z.json new file mode 100644 index 00000000000..87d8634572f --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-baseline-2026-07-02T07-23-46-933Z.json @@ -0,0 +1,1041 @@ +{ + "label": "baseline", + "startedAt": "2026-07-02T07:23:46.933Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 2.700000047683716, + "ptyBindMs": 5853.5, + "firstOutputMs": 5875.900000035763, + "paintSettleMs": 5878.100000023842, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 476.9000000357628, + "firstOutputMs": 489.10000002384186, + "paintSettleMs": 493.10000002384186, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 381.60000002384186, + "firstOutputMs": 401, + "paintSettleMs": 404.10000002384186, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 1.300000011920929, + "ptyBindMs": 391.10000002384186, + "firstOutputMs": 405.80000001192093, + "paintSettleMs": 408.10000002384186, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 1.5, + "ptyBindMs": 454, + "firstOutputMs": 467.80000001192093, + "paintSettleMs": 474, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 2, + "ptyBindMs": 527.6999999880791, + "firstOutputMs": 544.5, + "paintSettleMs": 551.3999999761581, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 483.60000002384186, + "firstOutputMs": 498.5, + "paintSettleMs": 501.10000002384186, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 1.600000023841858, + "ptyBindMs": 492.89999997615814, + "firstOutputMs": 509.19999998807907, + "paintSettleMs": 517, + "timedOut": false + } + ], + "longtasks": [ + 411, 65, 184, 124, 191, 163, 141, 183, 59, 158, 217, 61, 61, 253, 89, 70, 248, 152, 73, 170, + 184, 160, 178, 176, 151 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 109, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 114.5, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 138.5, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 73, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.800000011920929, + "paneVisibleMs": 1.800000011920929, + "paintSettleMs": 79.60000002384186, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 43.80000001192093, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 125.89999997615814, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 116.5, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 51.80000001192093, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 45.19999998807907, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 50.30000001192093, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 97.89999997615814, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.0999999642372131, + "paneVisibleMs": 1.0999999642372131, + "paintSettleMs": 112.5, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 48.69999998807907, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.5, + "paintSettleMs": 35.09999996423721, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 28.30000001192093, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 0.8999999761581421, + "paneVisibleMs": 0.8999999761581421, + "paintSettleMs": 35.09999996423721, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1.300000011920929, + "paneVisibleMs": 1.300000011920929, + "paintSettleMs": 150.70000004768372, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.9000000357627869, + "paneVisibleMs": 1.9000000357627869, + "paintSettleMs": 95.60000002384186, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 75.79999995231628, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.2000000476837158, + "paneVisibleMs": 1.2000000476837158, + "paintSettleMs": 40.30000001192093, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1.5999999642372131, + "paneVisibleMs": 1.5999999642372131, + "paintSettleMs": 30.099999964237213, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 86.89999997615814, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.300000011920929, + "paneVisibleMs": 1.300000011920929, + "paintSettleMs": 133, + "timedOut": false + } + ], + "longtasks": [98, 109, 130, 66, 71, 117, 110, 94, 100, 144, 91, 64, 82, 122] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 202, + "ptyBindMs": 204, + "paintSettleMs": 211 + }, + { + "index": 1, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 406, + "ptyBindMs": 550, + "paintSettleMs": 563 + }, + { + "index": 2, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 373, + "ptyBindMs": 476, + "paintSettleMs": 488 + }, + { + "index": 3, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 205, + "ptyBindMs": 251, + "paintSettleMs": 258 + }, + { + "index": 4, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 345, + "ptyBindMs": 369, + "paintSettleMs": 379 + }, + { + "index": 5, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 149, + "ptyBindMs": 177, + "paintSettleMs": 184 + }, + { + "index": 6, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 147, + "ptyBindMs": 162, + "paintSettleMs": 170 + }, + { + "index": 7, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 178, + "ptyBindMs": 202, + "paintSettleMs": 208 + }, + { + "index": 8, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 336, + "ptyBindMs": 366, + "paintSettleMs": 375 + }, + { + "index": 9, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 157, + "ptyBindMs": 192, + "paintSettleMs": 200 + }, + { + "index": 10, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 140, + "ptyBindMs": 164, + "paintSettleMs": 173 + }, + { + "index": 11, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 159, + "ptyBindMs": 188, + "paintSettleMs": 199 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 2, + "p95": 3, + "max": 3 + }, + "ptyBindMs": { + "count": 8, + "median": 484, + "p95": 5854, + "max": 5854 + }, + "firstOutputMs": { + "count": 8, + "median": 499, + "p95": 5876, + "max": 5876 + }, + "paintSettleMs": { + "count": 8, + "median": 501, + "p95": 5878, + "max": 5878 + } + }, + "longtaskCount": 25, + "longtaskMaxMs": 411 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 1, + "p95": 2, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 2, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 80, + "p95": 139, + "max": 151 + } + }, + "longtaskCount": 14, + "longtaskMaxMs": 144 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 202, + "p95": 373, + "max": 406 + }, + "ptyBindMs": { + "count": 12, + "median": 204, + "p95": 476, + "max": 550 + }, + "paintSettleMs": { + "count": 12, + "median": 211, + "p95": 488, + "max": 563 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "f93c43c3-57ef-4811-8edc-73d924918ed8", + "activeWorktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "activeTabType": "terminal", + "activeTabId": "2a067075-6acf-4aca-a0a6-bc88c1e6abc7", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "f93c43c3-57ef-4811-8edc-73d924918ed8": 3 + }, + "ptyIdsByTabId": [ + "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "6a3ca793-864f-4182-bec5-5b044a884b9b", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "ab3a14da-e087-4677-9fa8-df990c149951", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": false + } + ] + }, + { + "tabId": "afdd9731-982a-4255-8c0e-b4360453f433", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": false + } + ] + }, + { + "tabId": "2a067075-6acf-4aca-a0a6-bc88c1e6abc7", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 1, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 50268.60000002384, + "maxDriftMs": 440.10000002384186, + "samples": 335, + "startedAt": 25060.600000023842, + "lastTickAt": 50268.60000002384 + }, + "ptySessions": { + "value": [ + { + "id": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "cwd": "", + "title": "shell" + }, + { + "id": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "cwd": "", + "title": "shell" + }, + { + "id": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 71875, + "appLogsTail": [ + { + "source": "stderr", + "line": "https://electronjs.org/docs/tutorial/security.", + "at": 1782977071571 + }, + { + "source": "stderr", + "line": "This warning will not show up", + "at": 1782977071571 + }, + { + "source": "stderr", + "line": "once the app is packaged.\", source: node:electron/js2c/sandbox_bundle (2)", + "at": 1782977071571 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-settings-done t=30952 rendererT=23288 durationMs=454", + "at": 1782977071792 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-settings-done t=31069 rendererT=23405 durationMs=533", + "at": 1782977071909 + }, + { + "source": "stderr", + "line": "[50192:0702/032432.013:INFO:CONSOLE:1078] \"%cReact Grab v0.1.34%c", + "at": 1782977072013 + }, + { + "source": "stderr", + "line": "https://react-grab.com background: #330039; color: #ffffff; border: 1px solid #d75fcb; padding: 4px 4px 4px 24px; border-radius: 4px; background-image: url(\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyOTQgMjk0Ij48cGF0aCBmaWxsPSIjZmY0MGUwIiBkPSJNMTQ1IDQ3YzI1LTIwIDUwLTI3IDY3LTE3IDE2IDkgMjMgMzAgMjAgNjAgMCAyLTEgNS0xIDdsLTIgMTNoLTFsLTEyLTRjLTgtMy0xNy01LTI1LTZsLTE3LTNjLTEwLTEtMjAtMS0yOS0xLTEwIDAtMjAgMC0yOSAxLTYgOC0xMSAxNi0xNiAyNC01IDktOSAxNy0xMyAyNiA0IDkgOCAxOCAxMyAyNnMxMCAxNiAxNiAyNGwxMSAxNGM1IDcgMTEgMTMgMTggMTlsMTAgOHMtMSAwIDAgMGwtMTEgOWMtMTcgMTQtMzUgMjItNDkgMjItNiAwLTEzLTItMTgtNS0xNi05LTIzLTMwLTIwLTU5IDEtMyAxLTUgMS04LTMwLTEyLTQ4LTI5LTQ4LTUwIDAtMTggMTQtMzUgNDEtNDcgMi0xIDUtMiA3LTMgMC0yIDAtNS0xLTctMy0zMCA0LTUxIDIwLTYwIDE4LTEwIDQyLTMgNjggMTdNNzEgMjAxYy0xIDItMSA0LTEgNS0yIDI0IDMgNDEgMTMgNDdoMWMxMSA3IDMwIDEgNTEtMTUtMTAtOS0xOC0xOC0yNi0yOS0xMy0xLTI2LTQtMzgtOG05LTM4Yy0zIDktNSAxNy03IDI2IDggMiAxNyA0IDI1IDYtMy01LTYtMTAtOS0xNi0zLTUtNi0xMC05LTE2bS0xOS01M2MtMiAxLTMgMS01IDItMjEgMTAtMzQgMjMtMzQgMzUgMCAxMyAxNCAyNyAzOSAzNyAzLTEyIDctMjUgMTItMzctNS0xMi05LTI0LTEyLTM3bTM3LTEwYy04IDEtMTcgMy0yNSA2IDIgOCA0IDE2IDcgMjUgMy01IDYtMTEgOS0xNnptLTMtNjFjLTQtMS04IDAtMTIgMi0xMCA3LTE1IDI0LTEzIDQ3IDAgMiAwIDMgMSA1IDEyLTQgMjUtNiAzOC04IDgtMTAgMTYtMjAgMjYtMjktMTUtMTEtMjktMTctNDAtMTdtMTExIDJjLTQtMi04LTMtMTItMi0xMSAwLTI1IDUtNDAgMTcgMTAgOSAxOSAxOSAyNiAyOSAxMyAyIDI2IDQgMzkgOHYtNWMzLTIzLTItNDAtMTMtNDdtLTYxIDIzYy03IDYtMTMgMTMtMTkgMTloMzdjLTYtNi0xMi0xMy0xOC0xOSIvPjxtYXNrIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJtMjM1IDg1LTEzMyAyNyAyOCAxMzMgMTMzLTI3WiIvPjwvbWFzaz48cGF0aCBmaWxsPSIjZmY0MGUwIiBkPSJtMTM3IDEzMCA3NiAxMWM4IDEgOSAxMSAzIDE1bC0yOCAxNi00IDMyYy0xIDgtMTAgMTAtMTQgNGwtNDEtNjZjLTMtNiAxLTEzIDgtMTIiIG1hc2s9InVybCgjYSkiLz48L3N2Zz4=\"); background-size: 16px 16px; background-repeat: no-repeat; background-position: 4px center; display: inline-block; margin-bottom: 4px; \", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/react-grab.js?v=e74fde05 (1078)", + "at": 1782977072013 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=31229 maxGapMs=223", + "at": 1782977072070 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-repos-done t=31330 rendererT=23666 durationMs=378", + "at": 1782977072171 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-repos-done t=31330 rendererT=23667 durationMs=262", + "at": 1782977072171 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-project-groups-done t=31425 rendererT=23762 durationMs=95", + "at": 1782977072266 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-project-groups-done t=31426 rendererT=23762 durationMs=96", + "at": 1782977072266 + }, + { + "source": "stderr", + "line": "[50192:0702/032432.274:INFO:CONSOLE:1080] \"[React Grab] v0.1.34 is outdated (latest: v0.1.47). Run `npx grab@latest upgrade` to upgrade.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/react-grab.js?v=e74fde05 (1080)", + "at": 1782977072274 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=31469 rendererT=23805 durationMs=43", + "at": 1782977072309 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=31469 rendererT=23806 durationMs=1", + "at": 1782977072310 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=31470 rendererT=23807 durationMs=44", + "at": 1782977072311 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=31470 rendererT=23807 durationMs=0", + "at": 1782977072311 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=31486 rendererT=23823 durationMs=16", + "at": 1782977072327 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=31490 rendererT=23827 durationMs=20", + "at": 1782977072331 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=31511 rendererT=23848 durationMs=25", + "at": 1782977072352 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=31511 rendererT=23848 durationMs=0", + "at": 1782977072352 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=31512 rendererT=23848 durationMs=22", + "at": 1782977072353 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=31515 rendererT=23851 durationMs=3", + "at": 1782977072355 + }, + { + "source": "stderr", + "line": "[50192:0702/032432.892:INFO:CONSOLE:397] \"Tooltip is changing from uncontrolled to controlled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=33341407 (397)", + "at": 1782977072892 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=32052 rendererT=24110 durationMs=262", + "at": 1782977072893 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=32054 rendererT=24149 durationMs=298", + "at": 1782977072894 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=32142 rendererT=24479 durationMs=368", + "at": 1782977072983 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=32218 rendererT=24554 durationMs=405", + "at": 1782977073059 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=32222 rendererT=24559 durationMs=5", + "at": 1782977073063 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=32225 rendererT=24560 durationMs=1", + "at": 1782977073066 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=32574 rendererT=24606 durationMs=46", + "at": 1782977073415 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=32587 rendererT=24924 durationMs=318", + "at": 1782977073428 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=32588 rendererT=24924 connectionIds=0", + "at": 1782977073428 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=32628 rendererT=24965 durationMs=41", + "at": 1782977073472 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=32675 rendererT=25011 durationMs=46", + "at": 1782977073515 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=32677 rendererT=25013 durationMs=2142", + "at": 1782977073517 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=33422 maxGapMs=401", + "at": 1782977074262 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=35433 maxGapMs=309", + "at": 1782977076274 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=37456 maxGapMs=10", + "at": 1782977078296 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.587:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=ab3a14da-e087-4677-9fa8-df990c149951 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=ab3a14da-e087-4677-9fa8-df990c149951:4f90bd45-a941-4d4f-b132-614d700c43c8\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078587 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.587:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078587 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.636:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=0d6b052e-77c8-463f-afc9-d8f151d2cdeb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=0d6b052e-77c8-463f-afc9-d8f151d2cdeb:69c12565-c05a-493d-8de2-11ff97619d51\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078637 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.636:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078637 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=39469 maxGapMs=57", + "at": 1782977080309 + }, + { + "source": "stderr", + "line": "[50192:0702/032441.957:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=c9f77dc6-cd6f-4965-809c-c09127a40a03 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=c9f77dc6-cd6f-4965-809c-c09127a40a03:f1346d6f-965f-41fb-924b-764c7e3fc305\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977081957 + }, + { + "source": "stderr", + "line": "[50192:0702/032441.957:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977081957 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=41474 maxGapMs=48", + "at": 1782977082314 + }, + { + "source": "stderr", + "line": "[50192:0702/032442.858:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=cfb07cef-9bf1-4c78-996b-849186b593a4 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=cfb07cef-9bf1-4c78-996b-849186b593a4:de8f4a47-b5c9-4bf7-a650-1689aba5a8a8\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977082858 + }, + { + "source": "stderr", + "line": "[50192:0702/032442.858:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977082858 + }, + { + "source": "stderr", + "line": "[50192:0702/032443.702:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=3a994118-6004-4225-9c79-b7f00e7d72f1 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=3a994118-6004-4225-9c79-b7f00e7d72f1:89f847c7-b204-4b3a-b4cf-06f5c8c513bd\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977083702 + }, + { + "source": "stderr", + "line": "[50192:0702/032443.702:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977083702 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=43486 maxGapMs=53", + "at": 1782977084327 + }, + { + "source": "stderr", + "line": "[50192:0702/032444.556:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8fc5cf91-915a-4244-a4da-1724f3682004 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8fc5cf91-915a-4244-a4da-1724f3682004:805fa077-9c84-409e-95c0-d6974394f92a\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977084556 + }, + { + "source": "stderr", + "line": "[50192:0702/032444.556:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977084556 + }, + { + "source": "stderr", + "line": "[50192:0702/032444.635:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=33341407 (397)", + "at": 1782977084635 + }, + { + "source": "stderr", + "line": "[50192:0702/032445.429:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=9fa0c94f-508a-44a0-b8ba-5c24ac4ee536 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=9fa0c94f-508a-44a0-b8ba-5c24ac4ee536:89760fac-065f-4c31-a88a-3d57536749a6\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977085430 + }, + { + "source": "stderr", + "line": "[50192:0702/032445.429:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977085430 + }, + { + "source": "stderr", + "line": "[50192:0702/032446.318:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=be158070-c785-4cea-aca9-a94e71675335 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=be158070-c785-4cea-aca9-a94e71675335:8f686ee6-1816-4b55-8368-0cf249d9ca92\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977086318 + }, + { + "source": "stderr", + "line": "[50192:0702/032446.318:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977086319 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=45527 maxGapMs=50", + "at": 1782977086368 + }, + { + "source": "stderr", + "line": "[50192:0702/032447.232:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=257d758c-e00a-45f4-9543-41bb82652d68 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=257d758c-e00a-45f4-9543-41bb82652d68:d3e9824c-9691-4f5d-8bfa-8e7099c095a3\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977087232 + }, + { + "source": "stderr", + "line": "[50192:0702/032447.232:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977087233 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.177:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=3db20953-661a-4e65-8e4c-ca6a6f9d0538 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=3db20953-661a-4e65-8e4c-ca6a6f9d0538:621eeeff-c3b9-4558-bb55-fe3470e05025\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088177 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.177:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088178 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=47556 maxGapMs=42", + "at": 1782977088397 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.612:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=f4dca755-e41c-4f03-aa25-ed67526ca90e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=f4dca755-e41c-4f03-aa25-ed67526ca90e:edcb7b76-70ec-4c21-b6c1-be29e795657d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088612 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.612:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088612 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.077:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8f40ca49-476c-4959-ae9f-f976293e0b62 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8f40ca49-476c-4959-ae9f-f976293e0b62:f690ab96-e8c7-416a-a8e2-26cc38e11325\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089078 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.078:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089078 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.492:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=d7adfbbb-a7eb-48ed-876b-440835e7174f restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=d7adfbbb-a7eb-48ed-876b-440835e7174f:dee0c9f1-8ff4-4c92-894b-178d9a23b3a0\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089493 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.493:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089494 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=49559 maxGapMs=57", + "at": 1782977090400 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=51588 maxGapMs=12", + "at": 1782977092429 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=53613 maxGapMs=12", + "at": 1782977094454 + }, + { + "source": "stderr", + "line": "[50192:0702/032455.950:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=afdd9731-982a-4255-8c0e-b4360453f433 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=afdd9731-982a-4255-8c0e-b4360453f433:e6721533-a824-4847-98d2-b2c9f073a233\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977095950 + }, + { + "source": "stderr", + "line": "[50192:0702/032455.950:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977095951 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=55626 maxGapMs=26", + "at": 1782977096467 + }, + { + "source": "stderr", + "line": "[50192:0702/032456.469:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2a067075-6acf-4aca-a0a6-bc88c1e6abc7 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2a067075-6acf-4aca-a0a6-bc88c1e6abc7:6a3ca793-864f-4182-bec5-5b044a884b9b\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977096469 + }, + { + "source": "stderr", + "line": "[50192:0702/032456.469:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977096469 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=57642 maxGapMs=30", + "at": 1782977098483 + } + ] +} diff --git a/tools/benchmarks/results/terminal-perf-fix-a-b-2026-07-02T07-46-58-178Z.json b/tools/benchmarks/results/terminal-perf-fix-a-b-2026-07-02T07-46-58-178Z.json new file mode 100644 index 00000000000..ece15c26cfc --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-fix-a-b-2026-07-02T07-46-58-178Z.json @@ -0,0 +1,1058 @@ +{ + "label": "fix-a-b", + "startedAt": "2026-07-02T07:46:58.178Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 3.099999964237213, + "ptyBindMs": 5504, + "firstOutputMs": 5550.799999952316, + "paintSettleMs": 5553.099999964237, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 518.2000000476837, + "firstOutputMs": 535.7000000476837, + "paintSettleMs": 544.7000000476837, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 2.399999976158142, + "ptyBindMs": 412.5999999642372, + "firstOutputMs": 432.89999997615814, + "paintSettleMs": 434.7999999523163, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 1.5, + "ptyBindMs": 388.9000000357628, + "firstOutputMs": 401.10000002384186, + "paintSettleMs": 408.60000002384186, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 2.800000011920929, + "ptyBindMs": 469, + "firstOutputMs": 482.5999999642372, + "paintSettleMs": 485.30000001192093, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 1.899999976158142, + "ptyBindMs": 499.60000002384186, + "firstOutputMs": 511.60000002384186, + "paintSettleMs": 519, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 2, + "ptyBindMs": 441.10000002384186, + "firstOutputMs": 455.60000002384186, + "paintSettleMs": 456.39999997615814, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 2.300000011920929, + "ptyBindMs": 312.80000001192093, + "firstOutputMs": 326.69999998807907, + "paintSettleMs": 334.19999998807907, + "timedOut": false + } + ], + "longtasks": [ + 404, 60, 184, 142, 208, 169, 148, 194, 87, 159, 211, 62, 67, 258, 95, 55, 226, 151, 53, 149, + 172, 142, 144, 56, 64 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.7000000476837158, + "paneVisibleMs": 1.7000000476837158, + "paintSettleMs": 95.5, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 40.5, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 129.5999999642372, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.899999976158142, + "paneVisibleMs": 1.899999976158142, + "paintSettleMs": 118.39999997615814, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.9000000357627869, + "paneVisibleMs": 1.9000000357627869, + "paintSettleMs": 86.60000002384186, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 34.80000001192093, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 43.59999996423721, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.7999999523162842, + "paneVisibleMs": 1.7999999523162842, + "paintSettleMs": 128.89999997615814, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 95.5, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 94.19999998807907, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1.5, + "paneVisibleMs": 1.5, + "paintSettleMs": 29.100000023841858, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.199999988079071, + "paneVisibleMs": 1.199999988079071, + "paintSettleMs": 35, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.800000011920929, + "paneVisibleMs": 1.800000011920929, + "paintSettleMs": 167.30000001192093, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 2.900000035762787, + "paneVisibleMs": 2.900000035762787, + "paintSettleMs": 141.9000000357628, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 57.69999998807907, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.7000000476837158, + "paneVisibleMs": 1.7000000476837158, + "paintSettleMs": 42.90000003576279, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 2.399999976158142, + "paneVisibleMs": 2.399999976158142, + "paintSettleMs": 49.30000001192093, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.2000000476837158, + "paneVisibleMs": 1.2000000476837158, + "paintSettleMs": 126.40000003576279, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 134.89999997615814, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 75.19999998807907, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 40.69999998807907, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.0999999642372131, + "paneVisibleMs": 1.0999999642372131, + "paintSettleMs": 40.80000001192093, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 131.89999997615814, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 146.9000000357628, + "timedOut": false + } + ], + "longtasks": [86, 123, 111, 73, 122, 90, 84, 108, 127, 52, 121, 129, 64, 124, 134] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 240, + "ptyBindMs": 247, + "paintSettleMs": 254 + }, + { + "index": 1, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 423, + "ptyBindMs": 573, + "paintSettleMs": 584 + }, + { + "index": 2, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 355, + "ptyBindMs": 441, + "paintSettleMs": 464 + }, + { + "index": 3, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 152, + "ptyBindMs": 177, + "paintSettleMs": 182 + }, + { + "index": 4, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 294, + "ptyBindMs": 330, + "paintSettleMs": 341 + }, + { + "index": 5, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 167, + "ptyBindMs": 192, + "paintSettleMs": 200 + }, + { + "index": 6, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 141, + "ptyBindMs": 158, + "paintSettleMs": 164 + }, + { + "index": 7, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 115, + "ptyBindMs": 139, + "paintSettleMs": 145 + }, + { + "index": 8, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 123, + "ptyBindMs": 147, + "paintSettleMs": 154 + }, + { + "index": 9, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 258, + "ptyBindMs": 284, + "paintSettleMs": 294 + }, + { + "index": 10, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 153, + "ptyBindMs": 176, + "paintSettleMs": 185 + }, + { + "index": 11, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 123, + "ptyBindMs": 151, + "paintSettleMs": 158 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 2, + "p95": 3, + "max": 3 + }, + "ptyBindMs": { + "count": 8, + "median": 469, + "p95": 5504, + "max": 5504 + }, + "firstOutputMs": { + "count": 8, + "median": 483, + "p95": 5551, + "max": 5551 + }, + "paintSettleMs": { + "count": 8, + "median": 485, + "p95": 5553, + "max": 5553 + } + }, + "longtaskCount": 25, + "longtaskMaxMs": 404 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 2, + "p95": 2, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 2, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 94, + "p95": 147, + "max": 167 + } + }, + "longtaskCount": 15, + "longtaskMaxMs": 134 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 167, + "p95": 355, + "max": 423 + }, + "ptyBindMs": { + "count": 12, + "median": 192, + "p95": 441, + "max": 573 + }, + "paintSettleMs": { + "count": 12, + "median": 200, + "p95": 464, + "max": 584 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "62e7f2fd-6fb6-446e-bded-2866c26a968e", + "activeWorktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "activeTabType": "terminal", + "activeTabId": "5cf37be5-4f5e-4e6f-a00c-5116e3f6127e", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "62e7f2fd-6fb6-446e-bded-2866c26a968e": 3 + }, + "ptyIdsByTabId": [ + "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "74365402-0e1f-4d28-b14b-212e48cb4584", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "5ae16ac3-2ecb-4d5b-bdc2-3751e9b5c6a8", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "212a99f7-8de7-4b20-9a0b-c8caba7656e4", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "5cf37be5-4f5e-4e6f-a00c-5116e3f6127e", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 3, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 36550, + "maxDriftMs": 436.4000000357628, + "samples": 329, + "startedAt": 12199.699999988079, + "lastTickAt": 36550 + }, + "ptySessions": { + "value": [ + { + "id": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "cwd": "", + "title": "shell" + }, + { + "id": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "cwd": "", + "title": "shell" + }, + { + "id": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 50260, + "appLogsTail": [ + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13843 rendererT=11282 durationMs=40", + "at": 1782978443148 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13844 rendererT=11282 durationMs=0", + "at": 1782978443148 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13858 rendererT=11297 durationMs=16", + "at": 1782978443163 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13860 rendererT=11299 durationMs=17", + "at": 1782978443165 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13873 rendererT=11312 durationMs=15", + "at": 1782978443178 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13873 rendererT=11312 durationMs=0", + "at": 1782978443178 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13875 rendererT=11314 durationMs=15", + "at": 1782978443180 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13875 rendererT=11314 durationMs=0", + "at": 1782978443180 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13891 rendererT=11330 durationMs=18", + "at": 1782978443196 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13893 rendererT=11332 durationMs=17", + "at": 1782978443198 + }, + { + "source": "stderr", + "line": "[55788:0702/034723.201:INFO:CONSOLE:1080] \"[React Grab] v0.1.34 is outdated (latest: v0.1.47). Run `npx grab@latest upgrade` to upgrade.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/react-grab.js?v=43681c89 (1080)", + "at": 1782978443201 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14296 rendererT=11390 durationMs=59", + "at": 1782978443601 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14297 rendererT=11429 durationMs=97", + "at": 1782978443602 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=14297 rendererT=11434 durationMs=5", + "at": 1782978443602 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=14297 rendererT=11436 durationMs=1", + "at": 1782978443602 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=14305 rendererT=11744 durationMs=308", + "at": 1782978443610 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=14306 rendererT=11745 durationMs=1", + "at": 1782978443611 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=14306 rendererT=11745 connectionIds=0", + "at": 1782978443611 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=14338 rendererT=11777 durationMs=32", + "at": 1782978443643 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=14736 maxGapMs=360", + "at": 1782978444041 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=14736 rendererT=11817 durationMs=41", + "at": 1782978444041 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=14737 rendererT=11818 durationMs=1577", + "at": 1782978444042 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=16763 maxGapMs=352", + "at": 1782978446068 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=18790 maxGapMs=8", + "at": 1782978448094 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.941:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=5ae16ac3-2ecb-4d5b-bdc2-3751e9b5c6a8 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=5ae16ac3-2ecb-4d5b-bdc2-3751e9b5c6a8:36d54ebb-a041-4133-b5da-c7d36f570603\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448941 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.941:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448941 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.994:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=d13ff0ea-ef9b-4cc0-bc75-de82d29e4c7e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=d13ff0ea-ef9b-4cc0-bc75-de82d29e4c7e:375f8b17-83a7-4b05-9794-8c7e6daaad79\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448994 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.994:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448994 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=20813 maxGapMs=95", + "at": 1782978450118 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce total=1879ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=1829ms daemon=true reattach=false", + "at": 1782978450823 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@15f1dbf7 total=1880ms preflight=0ms auth=0ms host_env=55ms options=0ms provider_spawn=1825ms daemon=true reattach=false", + "at": 1782978450875 + }, + { + "source": "stderr", + "line": "[55788:0702/034731.745:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2b742216-def2-45d8-a554-afa8b63a7efc restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2b742216-def2-45d8-a554-afa8b63a7efc:6328b254-57e8-479e-bf53-220dd8fbefed\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978451746 + }, + { + "source": "stderr", + "line": "[55788:0702/034731.745:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978451746 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@1ee97184 total=118ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978451864 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=22815 maxGapMs=57", + "at": 1782978452120 + }, + { + "source": "stderr", + "line": "[55788:0702/034732.691:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=a669eb6f-24a3-4e93-9b9d-230b1734818a restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=a669eb6f-24a3-4e93-9b9d-230b1734818a:97eecd6f-f5df-41f6-8d16-0052bd0dd085\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978452692 + }, + { + "source": "stderr", + "line": "[55788:0702/034732.691:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978452692 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@04171bdd total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "at": 1782978452806 + }, + { + "source": "stderr", + "line": "[55788:0702/034733.549:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=a7f4f0da-28f3-4938-b412-0b5c7d389a1a restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=a7f4f0da-28f3-4938-b412-0b5c7d389a1a:0e97486f-93f7-4de1-a921-d7883bf6742d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978453549 + }, + { + "source": "stderr", + "line": "[55788:0702/034733.550:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978453549 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@46952210 total=97ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=51ms daemon=true reattach=false", + "at": 1782978453647 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=24819 maxGapMs=45", + "at": 1782978454124 + }, + { + "source": "stderr", + "line": "[55788:0702/034734.418:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=33f0654c-45be-4686-9f91-934dbd838341 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=33f0654c-45be-4686-9f91-934dbd838341:aa40fd80-39ca-4647-833f-081ff4315faf\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978454418 + }, + { + "source": "stderr", + "line": "[55788:0702/034734.418:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978454419 + }, + { + "source": "stderr", + "line": "[55788:0702/034734.488:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=43681c89 (397)", + "at": 1782978454488 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@c7cc0a5b total=98ms preflight=0ms auth=0ms host_env=45ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "at": 1782978454518 + }, + { + "source": "stderr", + "line": "[55788:0702/034735.279:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=acf961b2-58e1-4700-8246-8fbd13aec1e9 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=acf961b2-58e1-4700-8246-8fbd13aec1e9:d19e1bbf-4575-4b3c-b09e-b674244b0126\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978455279 + }, + { + "source": "stderr", + "line": "[55788:0702/034735.279:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978455279 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@07c16de2 total=110ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978455389 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=26824 maxGapMs=40", + "at": 1782978456129 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.131:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6c82b2e6-a836-47cc-9450-d81aab89a3ca restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6c82b2e6-a836-47cc-9450-d81aab89a3ca:fa45ccbe-b10e-4260-850c-350edc40a8f5\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456131 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.131:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456131 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@316fcc20 total=96ms preflight=0ms auth=0ms host_env=44ms options=0ms provider_spawn=52ms daemon=true reattach=false", + "at": 1782978456228 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.986:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=cc574a5e-9ee8-416a-acb3-9042ca35caf1 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=cc574a5e-9ee8-416a-acb3-9042ca35caf1:5e19c1dc-6360-4032-99f6-cb0345e5d7e5\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456986 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.987:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456987 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@6993e5e7 total=95ms preflight=0ms auth=0ms host_env=39ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "at": 1782978457082 + }, + { + "source": "stderr", + "line": "[55788:0702/034737.848:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8d57a052-276e-4882-ade4-dcc5278b822c restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8d57a052-276e-4882-ade4-dcc5278b822c:e39bef7b-f078-40f5-b29e-83468097b694\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978457848 + }, + { + "source": "stderr", + "line": "[55788:0702/034737.848:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978457848 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@56d1407f total=114ms preflight=0ms auth=0ms host_env=47ms options=1ms provider_spawn=66ms daemon=true reattach=false", + "at": 1782978457962 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=28852 maxGapMs=58", + "at": 1782978458157 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.252:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=d0231601-75d6-4b5e-b6f9-edae135f3beb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=d0231601-75d6-4b5e-b6f9-edae135f3beb:0ce6efc9-3d20-49df-a9cc-8ee9dec16cc3\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458252 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.252:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458252 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@d077bcb8 total=106ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "at": 1782978458358 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.803:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=37b9b3da-9361-47ea-b14e-eb9bc33fd17a restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=37b9b3da-9361-47ea-b14e-eb9bc33fd17a:ea9a8021-01d2-440f-a587-809680c60baf\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458803 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.803:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458804 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@f465a9a7 total=118ms preflight=1ms auth=0ms host_env=48ms options=0ms provider_spawn=69ms daemon=true reattach=false", + "at": 1782978458921 + }, + { + "source": "stderr", + "line": "[55788:0702/034739.203:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=ac3abf8a-ed89-4c51-83ae-5fca5e395ed5 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=ac3abf8a-ed89-4c51-83ae-5fca5e395ed5:d46a8d0d-69a8-4f14-8186-6f094f152d36\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978459203 + }, + { + "source": "stderr", + "line": "[55788:0702/034739.203:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978459203 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@2223d5a6 total=113ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=59ms daemon=true reattach=false", + "at": 1782978459316 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=30878 maxGapMs=43", + "at": 1782978460183 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=32902 maxGapMs=12", + "at": 1782978462207 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=34905 maxGapMs=14", + "at": 1782978464210 + }, + { + "source": "stderr", + "line": "[55788:0702/034745.917:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=212a99f7-8de7-4b20-9a0b-c8caba7656e4 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=212a99f7-8de7-4b20-9a0b-c8caba7656e4:dcbaade8-8868-4626-b685-37a662d40aa0\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978465917 + }, + { + "source": "stderr", + "line": "[55788:0702/034745.917:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978465917 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8 total=108ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "at": 1782978466025 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=36929 maxGapMs=46", + "at": 1782978466234 + }, + { + "source": "stderr", + "line": "[55788:0702/034746.426:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=5cf37be5-4f5e-4e6f-a00c-5116e3f6127e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=5cf37be5-4f5e-4e6f-a00c-5116e3f6127e:74365402-0e1f-4d28-b14b-212e48cb4584\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978466425 + }, + { + "source": "stderr", + "line": "[55788:0702/034746.426:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978466426 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07 total=109ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=57ms daemon=true reattach=false", + "at": 1782978466535 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=38934 maxGapMs=40", + "at": 1782978468239 + } + ], + "ptySpawnTimings": [ + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce total=1879ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=1829ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@15f1dbf7 total=1880ms preflight=0ms auth=0ms host_env=55ms options=0ms provider_spawn=1825ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@1ee97184 total=118ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@04171bdd total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@46952210 total=97ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=51ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@c7cc0a5b total=98ms preflight=0ms auth=0ms host_env=45ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@07c16de2 total=110ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@316fcc20 total=96ms preflight=0ms auth=0ms host_env=44ms options=0ms provider_spawn=52ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@6993e5e7 total=95ms preflight=0ms auth=0ms host_env=39ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@56d1407f total=114ms preflight=0ms auth=0ms host_env=47ms options=1ms provider_spawn=66ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@d077bcb8 total=106ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@f465a9a7 total=118ms preflight=1ms auth=0ms host_env=48ms options=0ms provider_spawn=69ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@2223d5a6 total=113ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=59ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8 total=108ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07 total=109ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=57ms daemon=true reattach=false" + ] +} diff --git a/tools/benchmarks/results/terminal-perf-fix-abd-2026-07-02T07-51-00-936Z.json b/tools/benchmarks/results/terminal-perf-fix-abd-2026-07-02T07-51-00-936Z.json new file mode 100644 index 00000000000..ae913a7ecac --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-fix-abd-2026-07-02T07-51-00-936Z.json @@ -0,0 +1,1061 @@ +{ + "label": "fix-abd", + "startedAt": "2026-07-02T07:51:00.936Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 4.199999988079071, + "ptyBindMs": 6850.600000023842, + "firstOutputMs": 6893.800000011921, + "paintSettleMs": 6897.199999988079, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 2.299999952316284, + "ptyBindMs": 553.2999999523163, + "firstOutputMs": 576.5999999642372, + "paintSettleMs": 578.0999999642372, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 2.699999988079071, + "ptyBindMs": 565.6000000238419, + "firstOutputMs": 584.5, + "paintSettleMs": 585.4000000357628, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 2.899999976158142, + "ptyBindMs": 542.8999999761581, + "firstOutputMs": 571.1999999880791, + "paintSettleMs": 573.8000000119209, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 2.300000011920929, + "ptyBindMs": 423, + "firstOutputMs": 457.19999998807907, + "paintSettleMs": 464.80000001192093, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 2.399999976158142, + "ptyBindMs": 478.5999999642372, + "firstOutputMs": 497, + "paintSettleMs": 498.19999998807907, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 2.099999964237213, + "ptyBindMs": 531, + "firstOutputMs": 548.3999999761581, + "paintSettleMs": 549.3999999761581, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 3.199999988079071, + "ptyBindMs": 612, + "firstOutputMs": 624.0999999642372, + "paintSettleMs": 629.0999999642372, + "timedOut": false + } + ], + "longtasks": [ + 478, 78, 204, 149, 218, 203, 145, 218, 202, 165, 223, 189, 163, 210, 86, 162, 280, 68, 110, + 323, 66, 76, 345, 130, 114 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2.599999964237213, + "paneVisibleMs": 2.599999964237213, + "paintSettleMs": 158.5999999642372, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.399999976158142, + "paneVisibleMs": 2.399999976158142, + "paintSettleMs": 155.5, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.699999988079071, + "paneVisibleMs": 2.699999988079071, + "paintSettleMs": 99.39999997615814, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.199999988079071, + "paneVisibleMs": 2.199999988079071, + "paintSettleMs": 66.30000001192093, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 87.89999997615814, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.599999964237213, + "paneVisibleMs": 2.599999964237213, + "paintSettleMs": 158.5, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.299999952316284, + "paneVisibleMs": 2.299999952316284, + "paintSettleMs": 150, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 70.19999998807907, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 60.19999998807907, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 155.0999999642372, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 159.5, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 68.70000004768372, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 66.39999997615814, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 54.90000003576279, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.199999988079071, + "paneVisibleMs": 2.199999988079071, + "paintSettleMs": 149.19999998807907, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.400000035762787, + "paintSettleMs": 151.9000000357628, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 90.59999996423721, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 57.30000001192093, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 59.30000001192093, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 143.19999998807907, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 117.80000001192093, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 94, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 1.5999999642372131, + "paneVisibleMs": 1.5999999642372131, + "paintSettleMs": 56.299999952316284, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 1.7000000476837158, + "paneVisibleMs": 1.7000000476837158, + "paintSettleMs": 134.10000002384186, + "timedOut": false + } + ], + "longtasks": [ + 145, 146, 89, 54, 80, 151, 138, 62, 53, 143, 151, 60, 59, 53, 142, 141, 83, 50, 129, 112, + 83, 126 + ] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 281, + "ptyBindMs": 283, + "paintSettleMs": 293 + }, + { + "index": 1, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 344, + "ptyBindMs": 524, + "paintSettleMs": 538 + }, + { + "index": 2, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 428, + "ptyBindMs": 545, + "paintSettleMs": 559 + }, + { + "index": 3, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 147, + "ptyBindMs": 167, + "paintSettleMs": 180 + }, + { + "index": 4, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 320, + "ptyBindMs": 348, + "paintSettleMs": 356 + }, + { + "index": 5, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 125, + "ptyBindMs": 146, + "paintSettleMs": 155 + }, + { + "index": 6, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 135, + "ptyBindMs": 168, + "paintSettleMs": 175 + }, + { + "index": 7, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 129, + "ptyBindMs": 155, + "paintSettleMs": 166 + }, + { + "index": 8, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 121, + "ptyBindMs": 147, + "paintSettleMs": 154 + }, + { + "index": 9, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 298, + "ptyBindMs": 318, + "paintSettleMs": 328 + }, + { + "index": 10, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 113, + "ptyBindMs": 131, + "paintSettleMs": 139 + }, + { + "index": 11, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 117, + "ptyBindMs": 140, + "paintSettleMs": 150 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 3, + "p95": 4, + "max": 4 + }, + "ptyBindMs": { + "count": 8, + "median": 553, + "p95": 6851, + "max": 6851 + }, + "firstOutputMs": { + "count": 8, + "median": 577, + "p95": 6894, + "max": 6894 + }, + "paintSettleMs": { + "count": 8, + "median": 578, + "p95": 6897, + "max": 6897 + } + }, + "longtaskCount": 25, + "longtaskMaxMs": 478 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 99, + "p95": 159, + "max": 160 + } + }, + "longtaskCount": 22, + "longtaskMaxMs": 151 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 147, + "p95": 344, + "max": 428 + }, + "ptyBindMs": { + "count": 12, + "median": 168, + "p95": 524, + "max": 545 + }, + "paintSettleMs": { + "count": 12, + "median": 180, + "p95": 538, + "max": 559 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d", + "activeWorktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "activeTabType": "terminal", + "activeTabId": "e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "f5b2567a-69b4-41a3-ba29-8c4bd357042d": 3 + }, + "ptyIdsByTabId": [ + "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "6aaaf90a-6cb7-48f9-b7c9-6869278038a7", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "93e4a957-3325-4987-a180-5dac10bebbc0", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "6f6ad43f-ecf2-47c1-b6dd-816bfc4638d7", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 3, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 39444.30000001192, + "maxDriftMs": 504, + "samples": 351, + "startedAt": 11431.800000011921, + "lastTickAt": 39444.30000001192 + }, + "ptySessions": { + "value": [ + { + "id": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "cwd": "", + "title": "shell" + }, + { + "id": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "cwd": "", + "title": "shell" + }, + { + "id": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 53646, + "appLogsTail": [ + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13271 rendererT=10691 durationMs=32", + "at": 1782978685796 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13272 rendererT=10691 durationMs=0", + "at": 1782978685797 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13288 rendererT=10708 durationMs=17", + "at": 1782978685813 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13290 rendererT=10709 durationMs=19", + "at": 1782978685815 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13303 rendererT=10723 durationMs=14", + "at": 1782978685828 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13303 rendererT=10723 durationMs=0", + "at": 1782978685828 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13305 rendererT=10725 durationMs=15", + "at": 1782978685830 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13305 rendererT=10725 durationMs=0", + "at": 1782978685830 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13313 rendererT=10733 durationMs=10", + "at": 1782978685838 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13314 rendererT=10734 durationMs=8", + "at": 1782978685839 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=13828 rendererT=10799 durationMs=66", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=13829 rendererT=10833 durationMs=99", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=13829 rendererT=10837 durationMs=4", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=13829 rendererT=10838 durationMs=1", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=13840 rendererT=11257 durationMs=418", + "at": 1782978686365 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=13842 rendererT=11260 durationMs=4", + "at": 1782978686367 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=13842 rendererT=11261 connectionIds=0", + "at": 1782978686367 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=13880 rendererT=11295 durationMs=34", + "at": 1782978686405 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=13914 rendererT=11334 durationMs=39", + "at": 1782978686439 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=13916 rendererT=11335 durationMs=1709", + "at": 1782978686441 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=14429 maxGapMs=446", + "at": 1782978686954 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=16441 maxGapMs=409", + "at": 1782978688966 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=18453 maxGapMs=7", + "at": 1782978690978 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.336:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=93e4a957-3325-4987-a180-5dac10bebbc0 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=93e4a957-3325-4987-a180-5dac10bebbc0:d285da80-e34b-422f-a0a4-14a68a6c5cb5\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692336 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.336:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692336 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.387:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=766de809-5863-49f7-a603-3faa5fdf4693 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=766de809-5863-49f7-a603-3faa5fdf4693:3747de82-0192-46c3-a130-e114e78bb00d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692387 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.387:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692387 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=20469 maxGapMs=30", + "at": 1782978692994 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=22492 maxGapMs=7", + "at": 1782978695018 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d total=2796ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=2749ms daemon=true reattach=false", + "at": 1782978695134 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ccc87346 total=2805ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=2753ms daemon=true reattach=false", + "at": 1782978695192 + }, + { + "source": "stderr", + "line": "[48724:0702/035136.133:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=9efaa7fd-442b-4efe-bf23-52d7143a0219 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=9efaa7fd-442b-4efe-bf23-52d7143a0219:df4d3047-f992-48ce-b5f1-6d0637523a9e\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978696133 + }, + { + "source": "stderr", + "line": "[48724:0702/035136.133:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978696133 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ec755bef total=116ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "at": 1782978696250 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=24506 maxGapMs=36", + "at": 1782978697031 + }, + { + "source": "stderr", + "line": "[48724:0702/035137.119:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=694f725c-29d1-4107-8648-81d285f6ab4b restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=694f725c-29d1-4107-8648-81d285f6ab4b:90413477-6d54-4dda-8c37-59e718741517\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978697120 + }, + { + "source": "stderr", + "line": "[48724:0702/035137.120:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978697120 + }, + { + "source": "stderr", + "line": "[48724:0702/035137.171:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=43681c89 (397)", + "at": 1782978697172 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@90e4e78e total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "at": 1782978697234 + }, + { + "source": "stderr", + "line": "[48724:0702/035138.128:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=adc6529c-4b75-45a8-8ea2-1bcf637da544 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=adc6529c-4b75-45a8-8ea2-1bcf637da544:91dc16c6-f567-4e05-994c-94ae19376ee7\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978698128 + }, + { + "source": "stderr", + "line": "[48724:0702/035138.128:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978698128 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@b172fe7e total=118ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978698247 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=26510 maxGapMs=51", + "at": 1782978699035 + }, + { + "source": "stderr", + "line": "[48724:0702/035139.097:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=f891d153-855c-43cb-9fdb-fd8682070724 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=f891d153-855c-43cb-9fdb-fd8682070724:5d0971c2-c663-4e44-ae1c-825dc948489a\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978699097 + }, + { + "source": "stderr", + "line": "[48724:0702/035139.097:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978699097 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@2ff2b79f total=113ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978699211 + }, + { + "source": "stderr", + "line": "[48724:0702/035140.045:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=939ae529-7ad7-4d46-b4c0-4b3997ab6b98 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=939ae529-7ad7-4d46-b4c0-4b3997ab6b98:b3224a5c-19ae-42b8-b756-965f04806e49\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978700046 + }, + { + "source": "stderr", + "line": "[48724:0702/035140.046:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978700046 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@0965fd8c total=114ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "at": 1782978700161 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.002:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=5749668e-dbd3-41ff-bbe5-c93c12925763 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=5749668e-dbd3-41ff-bbe5-c93c12925763:e3a00c11-d53b-4747-b215-ce9f19bb0da0\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701002 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.002:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701002 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=28532 maxGapMs=50", + "at": 1782978701057 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@9c46ce32 total=124ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=70ms daemon=true reattach=false", + "at": 1782978701126 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.986:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2546b0b6-5198-4067-b59b-698e914160d0 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2546b0b6-5198-4067-b59b-698e914160d0:fecf7693-17a1-4cdc-8d35-1ba415e0760d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701986 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.986:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701986 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ea5607d8 total=116ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "at": 1782978702102 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.024:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=87a752ed-2a66-411e-8c00-b7b0b24b5d10 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=87a752ed-2a66-411e-8c00-b7b0b24b5d10:9258e63f-4dee-455c-8f8f-543569e4ce24\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703024 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.024:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703025 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=30557 maxGapMs=47", + "at": 1782978703082 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@10666ccc total=116ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "at": 1782978703141 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=03cc8628-e72d-478c-90f7-3ed264097afb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=03cc8628-e72d-478c-90f7-3ed264097afb:b30207cc-752f-4836-8ea5-6f095e97f32d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703510 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703510 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@566d348e total=120ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "at": 1782978703630 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.065:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=993f77d3-4e18-4b17-b386-adead86c80c8 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=993f77d3-4e18-4b17-b386-adead86c80c8:ee549cc0-14c4-4211-922b-918f0810da85\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704066 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.066:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704066 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@aedde862 total=122ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=71ms daemon=true reattach=false", + "at": 1782978704188 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.838:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2e38aeb9-4c66-440e-adbf-de538b4ddcbb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2e38aeb9-4c66-440e-adbf-de538b4ddcbb:26a39262-f10c-4e3a-a246-8646617d0b05\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704838 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.838:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704838 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@dd291490 total=147ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=93ms daemon=true reattach=false", + "at": 1782978704985 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=32571 maxGapMs=116", + "at": 1782978705096 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=34594 maxGapMs=18", + "at": 1782978707119 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=36594 maxGapMs=13", + "at": 1782978709119 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=38594 maxGapMs=11", + "at": 1782978711119 + }, + { + "source": "stderr", + "line": "[48724:0702/035151.953:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6f6ad43f-ecf2-47c1-b6dd-816bfc4638d7 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6f6ad43f-ecf2-47c1-b6dd-816bfc4638d7:9002711a-d8da-4d46-a938-d1ab106322eb\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978711953 + }, + { + "source": "stderr", + "line": "[48724:0702/035151.953:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978711953 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f total=115ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=67ms daemon=true reattach=false", + "at": 1782978712070 + }, + { + "source": "stderr", + "line": "[48724:0702/035152.571:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083:6aaaf90a-6cb7-48f9-b7c9-6869278038a7\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978712571 + }, + { + "source": "stderr", + "line": "[48724:0702/035152.571:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978712571 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f total=118ms preflight=0ms auth=1ms host_env=54ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "at": 1782978712689 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=40618 maxGapMs=40", + "at": 1782978713143 + } + ], + "ptySpawnTimings": [ + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d total=2796ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=2749ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ccc87346 total=2805ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=2753ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ec755bef total=116ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@90e4e78e total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@b172fe7e total=118ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@2ff2b79f total=113ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@0965fd8c total=114ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@9c46ce32 total=124ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=70ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ea5607d8 total=116ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@10666ccc total=116ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@566d348e total=120ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@aedde862 total=122ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=71ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@dd291490 total=147ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=93ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f total=115ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=67ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f total=118ms preflight=0ms auth=1ms host_env=54ms options=0ms provider_spawn=63ms daemon=true reattach=false" + ] +} diff --git a/tools/benchmarks/results/terminal-perf-fix-abdf-2026-07-02T07-59-11-820Z.json b/tools/benchmarks/results/terminal-perf-fix-abdf-2026-07-02T07-59-11-820Z.json new file mode 100644 index 00000000000..c4e2c07b18f --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-fix-abdf-2026-07-02T07-59-11-820Z.json @@ -0,0 +1,1058 @@ +{ + "label": "fix-abdf", + "startedAt": "2026-07-02T07:59:11.820Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 3.299999952316284, + "ptyBindMs": 4816.899999976158, + "firstOutputMs": 4856.899999976158, + "paintSettleMs": 4864, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 2.800000011920929, + "ptyBindMs": 529, + "firstOutputMs": 554.5, + "paintSettleMs": 556.8000000119209, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 2.400000035762787, + "ptyBindMs": 423.10000002384186, + "firstOutputMs": 464.80000001192093, + "paintSettleMs": 470, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 1.800000011920929, + "ptyBindMs": 438.4000000357628, + "firstOutputMs": 453.69999998807907, + "paintSettleMs": 455.80000001192093, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 1.600000023841858, + "ptyBindMs": 497.10000002384186, + "firstOutputMs": 508.80000001192093, + "paintSettleMs": 515.8999999761581, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 471.80000001192093, + "firstOutputMs": 488.10000002384186, + "paintSettleMs": 491.30000001192093, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 1.199999988079071, + "ptyBindMs": 512.6999999880791, + "firstOutputMs": 524.2999999523163, + "paintSettleMs": 531.6999999880791, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 1.5, + "ptyBindMs": 468.10000002384186, + "firstOutputMs": 483.19999998807907, + "paintSettleMs": 484.19999998807907, + "timedOut": false + } + ], + "longtasks": [ + 52, 397, 67, 187, 133, 214, 192, 152, 196, 90, 154, 255, 61, 71, 296, 76, 68, 258, 92, 68, + 244, 161, 63, 176, 178, 137 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.9000000357627869, + "paneVisibleMs": 1.9000000357627869, + "paintSettleMs": 76.20000004768372, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.0999999642372131, + "paneVisibleMs": 1.0999999642372131, + "paintSettleMs": 113.19999998807907, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 130.9000000357628, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 70.09999996423721, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.899999976158142, + "paneVisibleMs": 1.899999976158142, + "paintSettleMs": 54.19999998807907, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 52.69999998807907, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 119.30000001192093, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 104.29999995231628, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 2.399999976158142, + "paneVisibleMs": 2.5, + "paintSettleMs": 78.39999997615814, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 49.59999996423721, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.300000011920929, + "paneVisibleMs": 1.300000011920929, + "paintSettleMs": 47.10000002384186, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 3.100000023841858, + "paneVisibleMs": 3.100000023841858, + "paintSettleMs": 120.30000001192093, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 108.19999998807907, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 51.69999998807907, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 45.30000001192093, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 44.69999998807907, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 58.69999998807907, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.899999976158142, + "paneVisibleMs": 1.899999976158142, + "paintSettleMs": 136.5999999642372, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 136.0999999642372, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 53.80000001192093, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 51.5, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.800000011920929, + "paneVisibleMs": 1.800000011920929, + "paintSettleMs": 122.40000003576279, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 118.5, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 62.69999998807907, + "timedOut": false + } + ], + "longtasks": [66, 102, 123, 64, 115, 96, 74, 113, 102, 52, 125, 130, 117, 110, 53] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 217, + "ptyBindMs": 219, + "paintSettleMs": 233 + }, + { + "index": 1, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 480, + "ptyBindMs": 575, + "paintSettleMs": 601 + }, + { + "index": 2, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 382, + "ptyBindMs": 557, + "paintSettleMs": 573 + }, + { + "index": 3, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 268, + "ptyBindMs": 295, + "paintSettleMs": 299 + }, + { + "index": 4, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 154, + "ptyBindMs": 178, + "paintSettleMs": 184 + }, + { + "index": 5, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 145, + "ptyBindMs": 166, + "paintSettleMs": 178 + }, + { + "index": 6, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 159, + "ptyBindMs": 184, + "paintSettleMs": 192 + }, + { + "index": 7, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 328, + "ptyBindMs": 349, + "paintSettleMs": 354 + }, + { + "index": 8, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 139, + "ptyBindMs": 158, + "paintSettleMs": 167 + }, + { + "index": 9, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 150, + "ptyBindMs": 177, + "paintSettleMs": 183 + }, + { + "index": 10, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 150, + "ptyBindMs": 169, + "paintSettleMs": 179 + }, + { + "index": 11, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 134, + "ptyBindMs": 166, + "paintSettleMs": 182 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 2, + "p95": 3, + "max": 3 + }, + "ptyBindMs": { + "count": 8, + "median": 497, + "p95": 4817, + "max": 4817 + }, + "firstOutputMs": { + "count": 8, + "median": 509, + "p95": 4857, + "max": 4857 + }, + "paintSettleMs": { + "count": 8, + "median": 516, + "p95": 4864, + "max": 4864 + } + }, + "longtaskCount": 26, + "longtaskMaxMs": 397 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 76, + "p95": 136, + "max": 137 + } + }, + "longtaskCount": 15, + "longtaskMaxMs": 130 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 159, + "p95": 382, + "max": 480 + }, + "ptyBindMs": { + "count": 12, + "median": 184, + "p95": 557, + "max": 575 + }, + "paintSettleMs": { + "count": 12, + "median": 192, + "p95": 573, + "max": 601 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b", + "activeWorktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "activeTabType": "terminal", + "activeTabId": "15055907-3232-4ca1-8814-e5484ba78a5b", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b": 3 + }, + "ptyIdsByTabId": [ + "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "78b7478c-39a9-4111-aed8-1e2ddf568c24", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "c46e04e4-af5f-4674-9a38-72253480baef", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "769013c6-b5b8-4be9-b188-4444a6094567", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "15055907-3232-4ca1-8814-e5484ba78a5b", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 3, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 35644.5, + "maxDriftMs": 413.89999997615814, + "samples": 309, + "startedAt": 11829.5, + "lastTickAt": 35644.5 + }, + "ptySessions": { + "value": [ + { + "id": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "cwd": "", + "title": "shell" + }, + { + "id": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "cwd": "", + "title": "shell" + }, + { + "id": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 50234, + "appLogsTail": [ + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13833 rendererT=11239 durationMs=53", + "at": 1782979177594 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13833 rendererT=11240 durationMs=1", + "at": 1782979177594 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13835 rendererT=11241 durationMs=55", + "at": 1782979177596 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13835 rendererT=11242 durationMs=0", + "at": 1782979177596 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13838 rendererT=11246 durationMs=6", + "at": 1782979177599 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13841 rendererT=11249 durationMs=8", + "at": 1782979177602 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13842 rendererT=11250 durationMs=4", + "at": 1782979177603 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13842 rendererT=11250 durationMs=0", + "at": 1782979177603 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13844 rendererT=11251 durationMs=2", + "at": 1782979177605 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13844 rendererT=11251 durationMs=0", + "at": 1782979177605 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13844 rendererT=11252 durationMs=2", + "at": 1782979177605 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13846 rendererT=11254 durationMs=3", + "at": 1782979177607 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14263 rendererT=11346 durationMs=94", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14263 rendererT=11426 durationMs=172", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=14263 rendererT=11430 durationMs=4", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=14263 rendererT=11431 durationMs=1", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=14277 rendererT=11685 durationMs=255", + "at": 1782979178038 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=14282 rendererT=11691 durationMs=5", + "at": 1782979178043 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=14283 rendererT=11691 connectionIds=0", + "at": 1782979178044 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=14324 rendererT=11732 durationMs=41", + "at": 1782979178085 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=14366 rendererT=11774 durationMs=42", + "at": 1782979178127 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=14367 rendererT=11775 durationMs=1992", + "at": 1782979178128 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=14580 maxGapMs=453", + "at": 1782979178342 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=16597 maxGapMs=336", + "at": 1782979180358 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=18612 maxGapMs=15", + "at": 1782979182373 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.829:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=c46e04e4-af5f-4674-9a38-72253480baef restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=c46e04e4-af5f-4674-9a38-72253480baef:8089e7c5-e739-4785-b3b4-6844f99c0a86\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182829 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.829:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182829 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.876:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=e62346f1-a808-4397-8be7-422efa0aaebb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=e62346f1-a808-4397-8be7-422efa0aaebb:e9b9d9ce-241c-458b-be93-01a657de7fd7\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182877 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.876:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182877 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88 total=1300ms preflight=0ms auth=1ms host_env=44ms options=0ms provider_spawn=1255ms daemon=true reattach=false", + "at": 1782979184130 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@2c485ebc total=1308ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=1259ms daemon=true reattach=false", + "at": 1782979184185 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=20628 maxGapMs=48", + "at": 1782979184389 + }, + { + "source": "stderr", + "line": "[47624:0702/035945.089:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=894d9793-e2ae-40bf-864e-eef357f271c1 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=894d9793-e2ae-40bf-864e-eef357f271c1:1b683a77-2230-4238-96eb-5118ea595604\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979185089 + }, + { + "source": "stderr", + "line": "[47624:0702/035945.089:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979185089 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@c3018b1f total=108ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=61ms daemon=true reattach=false", + "at": 1782979185197 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.036:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8fd459b0-a0a8-416d-b698-521da990ace3 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8fd459b0-a0a8-416d-b698-521da990ace3:74040bad-c0d6-42c6-af48-21150176be1c\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186035 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.036:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186035 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@59074e83 total=123ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=73ms daemon=true reattach=false", + "at": 1782979186159 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=22635 maxGapMs=34", + "at": 1782979186396 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.971:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6559b19f-a2e8-4691-8057-2efd9ba67d0e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6559b19f-a2e8-4691-8057-2efd9ba67d0e:8b41e717-29fe-49fb-a710-c96e41a1d112\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186971 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.971:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186971 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@562113d2 total=111ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "at": 1782979187082 + }, + { + "source": "stderr", + "line": "[47624:0702/035947.872:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=04620d6d-4629-40b3-8150-c7eec447d262 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=04620d6d-4629-40b3-8150-c7eec447d262:5992fb07-187a-43c3-9434-d424f1312155\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979187872 + }, + { + "source": "stderr", + "line": "[47624:0702/035947.873:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979187873 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@b5230dba total=105ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=57ms daemon=true reattach=false", + "at": 1782979187978 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=24659 maxGapMs=46", + "at": 1782979188420 + }, + { + "source": "stderr", + "line": "[47624:0702/035948.434:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=43681c89 (397)", + "at": 1782979188434 + }, + { + "source": "stderr", + "line": "[47624:0702/035948.761:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=bc4db6bc-b408-472a-9c15-77a428f98d99 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=bc4db6bc-b408-472a-9c15-77a428f98d99:11ff82bf-57d3-4e85-9293-77c0935f7d08\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979188761 + }, + { + "source": "stderr", + "line": "[47624:0702/035948.762:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979188761 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@95bb2f74 total=101ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=54ms daemon=true reattach=false", + "at": 1782979188862 + }, + { + "source": "stderr", + "line": "[47624:0702/035949.641:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=57b2a6d7-7fd2-41b2-9772-44e39bc9cee6 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=57b2a6d7-7fd2-41b2-9772-44e39bc9cee6:d186da0c-c1c4-45ca-90f1-e1f3b8fa4505\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979189640 + }, + { + "source": "stderr", + "line": "[47624:0702/035949.641:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979189640 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@09fc3731 total=94ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "at": 1782979189734 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=26688 maxGapMs=39", + "at": 1782979190449 + }, + { + "source": "stderr", + "line": "[47624:0702/035950.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6883a556-63dd-4c0a-87fa-9682db6f4cf0 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6883a556-63dd-4c0a-87fa-9682db6f4cf0:e0e844db-da79-459b-b791-52ea58bab4da\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979190509 + }, + { + "source": "stderr", + "line": "[47624:0702/035950.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979190510 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@fceb8b39 total=91ms preflight=0ms auth=0ms host_env=39ms options=1ms provider_spawn=51ms daemon=true reattach=false", + "at": 1782979190601 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.407:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e:c2c02ac2-e421-4c29-a8b6-4c97c11bf1dc\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191407 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.407:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191407 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@badd8d38 total=104ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "at": 1782979191511 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.824:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=66083463-228e-4fb0-90c5-231dc75e8da6 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=66083463-228e-4fb0-90c5-231dc75e8da6:e4fc0e83-57a8-44f0-9732-936e2914292b\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191824 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.824:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191824 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@ad09f3fe total=102ms preflight=0ms auth=0ms host_env=38ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782979191926 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.310:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=b99bb441-7027-40a9-beb2-a558ece6f979 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=b99bb441-7027-40a9-beb2-a558ece6f979:f5f1c1e2-72ac-4152-958b-b0b80e1213cc\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192310 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.311:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192310 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@0bb8c245 total=112ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782979192422 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=28705 maxGapMs=52", + "at": 1782979192466 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.723:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=490084ae-1bdb-4359-bb86-097e26902f98 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=490084ae-1bdb-4359-bb86-097e26902f98:c3647cbf-2f6e-4b4b-89c1-8bea8b949d66\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192723 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.723:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192723 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@3ef141e6 total=152ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=111ms daemon=true reattach=false", + "at": 1782979192875 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=30719 maxGapMs=43", + "at": 1782979194480 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=32744 maxGapMs=12", + "at": 1782979196505 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=34767 maxGapMs=14", + "at": 1782979198528 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.350:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=769013c6-b5b8-4be9-b188-4444a6094567 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=769013c6-b5b8-4be9-b188-4444a6094567:2a72038c-911c-4095-9fe8-0cd8c7f4978b\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199349 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.350:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199349 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26 total=113ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "at": 1782979199463 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.852:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=15055907-3232-4ca1-8814-e5484ba78a5b restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=15055907-3232-4ca1-8814-e5484ba78a5b:78b7478c-39a9-4111-aed8-1e2ddf568c24\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199852 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.853:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199853 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06 total=104ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=61ms daemon=true reattach=false", + "at": 1782979199957 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=36789 maxGapMs=49", + "at": 1782979200550 + } + ], + "ptySpawnTimings": [ + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88 total=1300ms preflight=0ms auth=1ms host_env=44ms options=0ms provider_spawn=1255ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@2c485ebc total=1308ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=1259ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@c3018b1f total=108ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=61ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@59074e83 total=123ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=73ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@562113d2 total=111ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@b5230dba total=105ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=57ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@95bb2f74 total=101ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=54ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@09fc3731 total=94ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@fceb8b39 total=91ms preflight=0ms auth=0ms host_env=39ms options=1ms provider_spawn=51ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@badd8d38 total=104ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@ad09f3fe total=102ms preflight=0ms auth=0ms host_env=38ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@0bb8c245 total=112ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@3ef141e6 total=152ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=111ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26 total=113ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06 total=104ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=61ms daemon=true reattach=false" + ] +} diff --git a/tools/benchmarks/terminal-perf-bench.mjs b/tools/benchmarks/terminal-perf-bench.mjs new file mode 100644 index 00000000000..f5c8646b696 --- /dev/null +++ b/tools/benchmarks/terminal-perf-bench.mjs @@ -0,0 +1,621 @@ +#!/usr/bin/env node +// Terminal interaction latency benchmark: tab creation, tab switching, and +// workspace switching against the real Electron dev app over CDP. Produces +// median/p95 phase timings so Windows terminal slowness can be attributed to +// store work, PTY spawn, shell startup, or renderer paint — not guessed at. + +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { + collectRendererDiagnostics, + connectToApp, + createGpuUserDataDirectory, + installRendererProbe, + launchDevApp, + pickFreePort, + stopDevApp, + waitForStoreReady +} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +import { + pollUntil, + rendererActionTimeoutMs, + runWithTimeout, + setupTimeoutMs +} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' +import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' + +const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const scenarioTimeoutMs = 300_000 +const defaultIterations = 8 +const defaultSwitches = 24 +const defaultCycles = 12 + +function parseArgs() { + const args = { + label: 'run', + iterations: defaultIterations, + switches: defaultSwitches, + cycles: defaultCycles, + shell: null, + scenarios: ['tab-create', 'tab-switch', 'workspace-switch'], + reportPath: null, + keep: false + } + for (const arg of process.argv.slice(2)) { + if (arg === '--help' || arg === '-h') { + printHelp() + process.exit(0) + } + if (arg === '--keep') { + args.keep = true + continue + } + const [name, value] = arg.split('=', 2) + if (name === '--label') { + args.label = value?.trim() || 'run' + continue + } + if (name === '--iterations') { + args.iterations = parsePositiveInt(name, value) + continue + } + if (name === '--switches') { + args.switches = parsePositiveInt(name, value) + continue + } + if (name === '--cycles') { + args.cycles = parsePositiveInt(name, value) + continue + } + if (name === '--shell') { + args.shell = value?.trim() || null + continue + } + if (name === '--scenarios') { + const scenarios = (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + const known = new Set(['tab-create', 'tab-switch', 'workspace-switch']) + if (scenarios.length === 0 || scenarios.some((scenario) => !known.has(scenario))) { + throw new Error( + `Unsupported --scenarios=${value}. Use tab-create,tab-switch,workspace-switch.` + ) + } + args.scenarios = scenarios + continue + } + if (name === '--report') { + args.reportPath = value?.trim() || null + continue + } + throw new Error(`Unknown argument: ${arg}`) + } + return args +} + +function printHelp() { + console.log(`Usage: + node tools/benchmarks/terminal-perf-bench.mjs [options] + +Options: + --label=NAME Label recorded in the report filename/JSON. Default: run. + --iterations=N Terminal tab create/close iterations. Default: ${defaultIterations}. + --switches=N Tab switch alternations. Default: ${defaultSwitches}. + --cycles=N Workspace switch cycles. Default: ${defaultCycles}. + --shell=PATH Shell override for created tabs (e.g. cmd.exe). Default: app default. + --scenarios=a,b Subset of tab-create,tab-switch,workspace-switch. + --report=PATH Report JSON path. Default: tools/benchmarks/results/terminal-perf-
- {sessionLeft !== null && ( -
-
+
+ {usageWindows.map((window) => ( +
+
- - {sessionLeft} - {translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h')} + + {window.left} + {window.label}
- )} - {weeklyLeft !== null && ( -
-
-
-
- - {weeklyLeft} - {translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk')} - -
- )} - {fableLeft !== null && ( -
-
-
-
- - {fableLeft} - {translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable')} - -
- )} - {limits.status === 'error' && !limits.session && !limits.weekly && !limits.fableWeekly && ( + ))} + {usageWindows.length === 0 && limits.status === 'error' ? ( {translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')} - )} + ) : null}
) } diff --git a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx new file mode 100644 index 00000000000..0160978beb7 --- /dev/null +++ b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx @@ -0,0 +1,53 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => null +})) + +function claudeLimits(): ProviderRateLimits { + return { + provider: 'claude', + session: { + usedPercent: 32, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 16, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 42, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + +describe('InlineUsageBars', () => { + it('renders Claude Fable usage in inactive account preview rows', async () => { + const { InlineUsageBars } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('68% 5h') + expect(markup).toContain('84% wk') + expect(markup).toContain('58% Fable') + }) +}) From 0fd2d67c699a6a221196110e184c4da3ec81864d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:23:18 -0700 Subject: [PATCH 50/70] Allow manual sorting of Project Groups (#7191) --- .../src/components/sidebar/WorktreeList.tsx | 104 +++++- .../sidebar/project-group-header-dom.test.ts | 25 ++ .../project-group-header-drag-commit.test.ts | 111 +++++++ .../project-group-header-drag-commit.ts | 50 +++ .../project-group-header-drag-contract.ts | 77 +++++ .../project-group-header-drag-start.ts | 64 ++++ .../sidebar/project-group-header-drag.ts | 304 ++++++++++++++++++ .../sidebar/project-group-header-drop.test.ts | 169 ++++++++++ .../sidebar/project-group-header-drop.ts | 210 ++++++++++++ .../sidebar/worktree-list-groups.test.ts | 61 ++++ 10 files changed, 1172 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/sidebar/project-group-header-dom.test.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag-commit.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag-contract.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag-start.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drop.test.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drop.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 2296df9064b..2e405dcfb2d 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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() 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() + for (const groupIds of sidebarProjectGroupHeaderIdsByBucket.values()) { + groupIds.forEach((groupId, index) => { + map.set(groupId, index) + }) + } + return map + }, [sidebarProjectGroupHeaderIdsByBucket]) + const projectGroupHeaderBucketByGroupId = useMemo(() => { + const map = new Map() + 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 ? ( +
+ ) : null} {hostDrag.state.draggingHostId !== null && hostDrag.state.dropIndicatorY !== null ? (
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 ? (
repoDrag.onHandlePointerDown(event, projectIdForHeader) - : undefined + : isDraggableProjectGroupHeader && projectGroupIdForHeader + ? (event) => + projectGroupDrag.onHandlePointerDown( + event, + projectGroupIdForHeader + ) + : undefined } > {row.repo ? ( diff --git a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts new file mode 100644 index 00000000000..266c85c4893 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts @@ -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 })') + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts b/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts new file mode 100644 index 00000000000..ef873d9bf02 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' + +import { commitProjectGroupHeaderDragDrop } from './project-group-header-drag-commit' +import type { ProjectGroupHeaderDragSession } from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +function group(id: string, overrides: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeSession( + groupId: string, + sidebarProjectGroupHeaderIds: readonly string[] +): ProjectGroupHeaderDragSession { + return { + groupId, + bucketKey: 'root', + sidebarProjectGroupHeaderIds, + pointerId: 1, + headerRects: [], + handleEl: document.createElement('div'), + startX: 0, + startY: 0, + latestPointerY: 0, + promoted: true + } +} + +describe('commitProjectGroupHeaderDragDrop', () => { + it('commits a finite tabOrder for the dragged Project Group', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [ + group('a', { tabOrder: 0 }), + group('b', { tabOrder: 10 }), + group('c', { tabOrder: 20 }) + ] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('c', ['a', 'b', 'c']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).toHaveBeenCalledWith('c', -1) + }) + + it('computes order only from the captured sibling bucket', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const root = group('root') + const siblingA = group('sibling-a', { parentGroupId: root.id, tabOrder: 0 }) + const siblingB = group('sibling-b', { parentGroupId: root.id, tabOrder: 10 }) + const otherParentGroup = group('other-parent-group', { tabOrder: -100 }) + const projectGroupById = new Map( + [root, siblingA, siblingB, otherParentGroup].map((entry) => [entry.id, entry]) + ) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('sibling-b', ['sibling-a', 'sibling-b']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).toHaveBeenCalledWith('sibling-b', -1) + }) + + it('does not commit when the drop keeps the group in the same slot', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [group('a'), group('b'), group('c')] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('b', ['a', 'b', 'c']), + sidebarDropIndex: 2, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).not.toHaveBeenCalled() + }) + + it('does not commit when a stale session no longer contains the dragged group', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [group('a'), group('b'), group('c')] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('c', ['a', 'b']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts b/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts new file mode 100644 index 00000000000..3ef85959577 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts @@ -0,0 +1,50 @@ +import { + getProjectGroupTabOrderForSidebarDrop, + mapSidebarProjectGroupDropIndexToSiblingInsertIndex +} from './project-group-header-drop' +import type { ProjectGroupHeaderDragSession } from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +export function commitProjectGroupHeaderDragDrop(args: { + session: ProjectGroupHeaderDragSession + sidebarDropIndex: number + projectGroupById: ReadonlyMap + onCommitProjectGroupTabOrder: (groupId: string, tabOrder: number) => void +}): void { + const draggedGroup = args.projectGroupById.get(args.session.groupId) + if (!draggedGroup) { + return + } + + const sidebarProjectGroupHeaderIds = args.session.sidebarProjectGroupHeaderIds + const sourceIndex = sidebarProjectGroupHeaderIds.indexOf(args.session.groupId) + if (sourceIndex === -1) { + return + } + if (args.sidebarDropIndex === sourceIndex) { + return + } + + const siblings = sidebarProjectGroupHeaderIds + .filter((groupId) => groupId !== args.session.groupId) + .map((groupId) => args.projectGroupById.get(groupId)) + .filter((group): group is ProjectGroup => group !== undefined) + const siblingDropIndex = mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: args.sidebarDropIndex, + sourceIndex, + siblingCount: siblings.length + }) + const sourceIndexInSiblings = Math.min(sourceIndex, siblings.length) + if (siblingDropIndex === sourceIndexInSiblings) { + return + } + + const tabOrder = getProjectGroupTabOrderForSidebarDrop({ + siblings, + dropIndex: siblingDropIndex + }) + if (!Number.isFinite(tabOrder)) { + return + } + args.onCommitProjectGroupTabOrder(draggedGroup.id, tabOrder) +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts b/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts new file mode 100644 index 00000000000..7127ce6bd8c --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts @@ -0,0 +1,77 @@ +import type { PointerEvent } from 'react' + +import type { + ProjectGroupHeaderDragBucketKey, + ProjectGroupHeaderDragRect +} from './project-group-header-drop' +import type { ProjectGroup } from '../../../../shared/types' + +export type ProjectGroupDragState = { + draggingGroupId: string | null + dropIndex: number | null + dropIndicatorY: number | null +} + +export const INITIAL_PROJECT_GROUP_DRAG_STATE: ProjectGroupDragState = { + draggingGroupId: null, + dropIndex: null, + dropIndicatorY: null +} + +export type UseProjectGroupHeaderDragArgs = { + sidebarProjectGroupHeaderIdsByBucket: ReadonlyMap< + ProjectGroupHeaderDragBucketKey, + readonly string[] + > + projectGroupById: ReadonlyMap + onCommitProjectGroupTabOrder: (groupId: string, tabOrder: number) => void + getScrollContainer: () => HTMLElement | null +} + +export type ProjectGroupHeaderDragController = { + state: ProjectGroupDragState + onHandlePointerDown: (event: PointerEvent, groupId: string) => void +} + +export type ProjectGroupHeaderDragSession = { + groupId: string + bucketKey: ProjectGroupHeaderDragBucketKey + sidebarProjectGroupHeaderIds: readonly string[] + pointerId: number + headerRects: ProjectGroupHeaderDragRect[] + handleEl: HTMLElement + startX: number + startY: number + latestPointerY: number + promoted: boolean +} + +export const PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX = 4 + +const PROJECT_GROUP_HEADER_DRAG_HANDLE_SELECTOR = '[data-project-group-header-drag-handle]' + +const PROJECT_GROUP_HEADER_ACTION_SELECTOR = + '[data-repo-header-action], [data-repo-header-collapse-affordance], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' + +export function isProjectGroupHeaderDragHandleTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement)) { + return false + } + const dragHandle = target.closest(PROJECT_GROUP_HEADER_DRAG_HANDLE_SELECTOR) + return dragHandle !== null && currentTarget.contains(dragHandle) +} + +export function isProjectGroupHeaderActionTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement) || target === currentTarget) { + return false + } + return ( + currentTarget.contains(target) && target.closest(PROJECT_GROUP_HEADER_ACTION_SELECTOR) !== null + ) +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-start.ts b/src/renderer/src/components/sidebar/project-group-header-drag-start.ts new file mode 100644 index 00000000000..89eb5bd9dd0 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-start.ts @@ -0,0 +1,64 @@ +import type { PointerEvent } from 'react' + +import { + getProjectGroupHeaderDragBucketKey, + measureProjectGroupHeaderDragRects, + type ProjectGroupHeaderDragBucketKey +} from './project-group-header-drop' +import { + isProjectGroupHeaderActionTarget, + isProjectGroupHeaderDragHandleTarget, + type ProjectGroupHeaderDragSession +} from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +export function createProjectGroupHeaderDragSession(args: { + event: PointerEvent + groupId: string + projectGroupById: ReadonlyMap + sidebarProjectGroupHeaderIdsByBucket: ReadonlyMap< + ProjectGroupHeaderDragBucketKey, + readonly string[] + > + getScrollContainer: () => HTMLElement | null +}): ProjectGroupHeaderDragSession | null { + if (args.event.button !== 0) { + return null + } + if (!isProjectGroupHeaderDragHandleTarget(args.event.target, args.event.currentTarget)) { + return null + } + if (isProjectGroupHeaderActionTarget(args.event.target, args.event.currentTarget)) { + return null + } + const group = args.projectGroupById.get(args.groupId) + if (!group) { + return null + } + const bucketKey = getProjectGroupHeaderDragBucketKey(group, args.projectGroupById) + const sidebarProjectGroupHeaderIds = + args.sidebarProjectGroupHeaderIdsByBucket.get(bucketKey) ?? [] + // Why: a lone group in a parent bucket cannot move without reparenting. + if (sidebarProjectGroupHeaderIds.length <= 1) { + return null + } + const container = args.getScrollContainer() + if (!container) { + return null + } + const handleEl = args.event.currentTarget + // Why: defer pointer capture until the threshold so ordinary group header + // clicks still toggle collapse through the row handler. + return { + groupId: args.groupId, + bucketKey, + sidebarProjectGroupHeaderIds, + pointerId: args.event.pointerId, + headerRects: measureProjectGroupHeaderDragRects(container, bucketKey), + handleEl, + startX: args.event.clientX, + startY: args.event.clientY, + latestPointerY: args.event.clientY, + promoted: false + } +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag.ts b/src/renderer/src/components/sidebar/project-group-header-drag.ts new file mode 100644 index 00000000000..6896dff069f --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag.ts @@ -0,0 +1,304 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { + computeProjectGroupHeaderDropPreview, + measureProjectGroupHeaderDragRects +} from './project-group-header-drop' +import { commitProjectGroupHeaderDragDrop } from './project-group-header-drag-commit' +import { + INITIAL_PROJECT_GROUP_DRAG_STATE, + PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX, + type ProjectGroupDragState, + type ProjectGroupHeaderDragController, + type ProjectGroupHeaderDragSession, + type UseProjectGroupHeaderDragArgs +} from './project-group-header-drag-contract' +import { createProjectGroupHeaderDragSession } from './project-group-header-drag-start' +import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll' + +// Why pointer events instead of HTML5 DnD: Project Group rows are virtualized +// and may unmount while scrolling; cached row-model indices keep drops stable. + +export function useProjectGroupHeaderDrag({ + sidebarProjectGroupHeaderIdsByBucket, + projectGroupById, + onCommitProjectGroupTabOrder, + getScrollContainer +}: UseProjectGroupHeaderDragArgs): ProjectGroupHeaderDragController { + const [state, setState] = useState(INITIAL_PROJECT_GROUP_DRAG_STATE) + const [sessionArmed, setSessionArmed] = useState(false) + const latestDropIndexRef = useRef(null) + latestDropIndexRef.current = state.dropIndex + const sidebarProjectGroupHeaderIdsByBucketRef = useRef(sidebarProjectGroupHeaderIdsByBucket) + sidebarProjectGroupHeaderIdsByBucketRef.current = sidebarProjectGroupHeaderIdsByBucket + const projectGroupByIdRef = useRef(projectGroupById) + projectGroupByIdRef.current = projectGroupById + const onCommitProjectGroupTabOrderRef = useRef(onCommitProjectGroupTabOrder) + onCommitProjectGroupTabOrderRef.current = onCommitProjectGroupTabOrder + const getContainerRef = useRef(getScrollContainer) + getContainerRef.current = getScrollContainer + const autoscrollLastFrameTimeRef = useRef(null) + const autoscrollFrameIdRef = useRef(null) + + const dragSessionRef = useRef(null) + const clickSwallowTimeoutRef = useRef | null>(null) + + const refreshHeaderRects = useCallback(() => { + const container = getContainerRef.current() + const session = dragSessionRef.current + if (!container || !session) { + return [] + } + const rects = measureProjectGroupHeaderDragRects(container, session.bucketKey) + session.headerRects = rects + return rects + }, []) + + const computeDrop = useCallback( + (pointerY: number): { dropIndex: number; dropIndicatorY: number } | null => { + const session = dragSessionRef.current + const container = getContainerRef.current() + if (!session || !container) { + return null + } + const containerRect = container.getBoundingClientRect() + return computeProjectGroupHeaderDropPreview({ + pointerY, + containerTop: containerRect.top, + scrollTop: container.scrollTop, + rects: session.headerRects, + sidebarProjectGroupHeaderIds: session.sidebarProjectGroupHeaderIds + }) + }, + [] + ) + + const cancelAutoscroll = useCallback(() => { + if (autoscrollFrameIdRef.current !== null) { + window.cancelAnimationFrame(autoscrollFrameIdRef.current) + autoscrollFrameIdRef.current = null + } + autoscrollLastFrameTimeRef.current = null + }, []) + + const endDrag = useCallback( + (commit: boolean) => { + cancelAutoscroll() + const session = dragSessionRef.current + if (!session) { + setState(INITIAL_PROJECT_GROUP_DRAG_STATE) + setSessionArmed(false) + return + } + try { + session.handleEl.releasePointerCapture(session.pointerId) + } catch { + // capture may already be released (pointercancel, element unmounted) + } + if (session.promoted) { + const handleEl = session.handleEl + const swallow = (event: MouseEvent): void => { + const target = event.target as Node | null + if (target && handleEl.contains(target)) { + event.stopPropagation() + event.preventDefault() + } + window.removeEventListener('click', swallow, true) + } + window.addEventListener('click', swallow, true) + clickSwallowTimeoutRef.current = setTimeout(() => { + window.removeEventListener('click', swallow, true) + clickSwallowTimeoutRef.current = null + }, 0) + } + const sidebarDropIndex = + commit && session.promoted && latestDropIndexRef.current !== null + ? latestDropIndexRef.current + : null + dragSessionRef.current = null + setState(INITIAL_PROJECT_GROUP_DRAG_STATE) + setSessionArmed(false) + if (sidebarDropIndex === null) { + return + } + + commitProjectGroupHeaderDragDrop({ + session, + sidebarDropIndex, + projectGroupById: projectGroupByIdRef.current, + onCommitProjectGroupTabOrder: onCommitProjectGroupTabOrderRef.current + }) + }, + [cancelAutoscroll] + ) + + const runAutoscrollFrame = useCallback( + (frameTime: number) => { + autoscrollFrameIdRef.current = null + const session = dragSessionRef.current + const container = getContainerRef.current() + if (!session?.promoted || !container) { + cancelAutoscroll() + return + } + + const previousFrameTime = autoscrollLastFrameTimeRef.current ?? frameTime + autoscrollLastFrameTimeRef.current = frameTime + const autoscroll = getWorktreeSidebarDragAutoscroll({ + point: { clientX: 0, clientY: session.latestPointerY }, + containerRect: container.getBoundingClientRect(), + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight, + elapsedMs: frameTime - previousFrameTime + }) + if (autoscroll) { + container.scrollTop = autoscroll.scrollTop + refreshHeaderRects() + } + + const drop = computeDrop(session.latestPointerY) + if (drop) { + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingGroupId: session.groupId, ...drop } + ) + } + + autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) + }, + [cancelAutoscroll, computeDrop, refreshHeaderRects] + ) + + const ensureAutoscroll = useCallback(() => { + if (autoscrollFrameIdRef.current !== null) { + return + } + autoscrollLastFrameTimeRef.current = null + autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) + }, [runAutoscrollFrame]) + + useEffect(() => { + if (!sessionArmed) { + return + } + const onPointerMove = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + session.latestPointerY = event.clientY + if (!session.promoted) { + const dx = event.clientX - session.startX + const dy = event.clientY - session.startY + if ( + dx * dx + dy * dy < + PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX * PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX + ) { + return + } + session.promoted = true + // Why: virtualized headers may detach during drag; global listeners + // still keep the operation alive if pointer capture is unavailable. + if (session.handleEl.isConnected) { + try { + session.handleEl.setPointerCapture(session.pointerId) + } catch { + // Ignore capture failure; global listeners will handle the drag. + } + } + refreshHeaderRects() + setState({ draggingGroupId: session.groupId, dropIndex: null, dropIndicatorY: null }) + } + refreshHeaderRects() + const drop = computeDrop(event.clientY) + if (drop) { + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingGroupId: session.groupId, ...drop } + ) + } + ensureAutoscroll() + } + const onPointerUp = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + endDrag(true) + } + const onPointerCancel = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + endDrag(false) + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + endDrag(false) + } + } + const onBlur = (): void => endDrag(false) + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('blur', onBlur) + return () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('blur', onBlur) + cancelAutoscroll() + if (clickSwallowTimeoutRef.current !== null) { + clearTimeout(clickSwallowTimeoutRef.current) + clickSwallowTimeoutRef.current = null + } + } + }, [cancelAutoscroll, computeDrop, endDrag, ensureAutoscroll, refreshHeaderRects, sessionArmed]) + + useEffect(() => { + if (state.draggingGroupId === null) { + return + } + const body = document.body + const prevCursor = body.style.cursor + const prevUserSelect = body.style.userSelect + body.style.cursor = 'grabbing' + body.style.userSelect = 'none' + return () => { + body.style.cursor = prevCursor + body.style.userSelect = prevUserSelect + } + }, [state.draggingGroupId]) + + const onHandlePointerDown = useCallback( + (event: React.PointerEvent, groupId: string) => { + const session = createProjectGroupHeaderDragSession({ + event, + groupId, + projectGroupById: projectGroupByIdRef.current, + sidebarProjectGroupHeaderIdsByBucket: sidebarProjectGroupHeaderIdsByBucketRef.current, + getScrollContainer: getContainerRef.current + }) + if (!session) { + return + } + dragSessionRef.current = session + setSessionArmed(true) + }, + [] + ) + + return { state, onHandlePointerDown } +} + +export { + isProjectGroupHeaderActionTarget, + isProjectGroupHeaderDragHandleTarget +} from './project-group-header-drag-contract' diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.test.ts b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts new file mode 100644 index 00000000000..5abf7648677 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' + +import { + computeProjectGroupHeaderDropPreview, + getProjectGroupHeaderDragBucketKey, + getProjectGroupTabOrderForSidebarDrop, + getSidebarOrderedProjectGroupHeaderIdsByBucket, + mapSidebarProjectGroupDropIndexToSiblingInsertIndex +} from './project-group-header-drop' +import type { Row } from './worktree-list-groups' +import type { ProjectGroup, Repo } from '../../../../shared/types' + +function group(id: string, overrides: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('getProjectGroupHeaderDragBucketKey', () => { + it('uses root for top-level groups', () => { + expect(getProjectGroupHeaderDragBucketKey(group('root'))).toBe('root') + }) + + it('scopes child groups to their parent bucket', () => { + const root = group('root') + const child = group('child', { parentGroupId: root.id }) + const groupsById = new Map([ + [root.id, root], + [child.id, child] + ]) + + expect(getProjectGroupHeaderDragBucketKey(child, groupsById)).toBe('parent:root') + }) + + it('falls back to root when persisted parent metadata is missing', () => { + const orphan = group('orphan', { parentGroupId: 'missing' }) + + expect(getProjectGroupHeaderDragBucketKey(orphan, new Map([[orphan.id, orphan]]))).toBe('root') + }) +}) + +describe('getSidebarOrderedProjectGroupHeaderIdsByBucket', () => { + it('groups Project Group headers by effective parent bucket', () => { + const rootA = group('root-a') + const rootB = group('root-b') + const childA = group('child-a', { parentGroupId: rootA.id }) + const repo = { id: 'repo-a', projectGroupId: rootA.id } as Repo + const groupsById = new Map([ + [rootA.id, rootA], + [rootB.id, rootB], + [childA.id, childA] + ]) + const rows = [ + { + type: 'header', + key: 'project-group:root-a', + label: 'A', + count: 0, + tone: '', + projectGroup: rootA + }, + { type: 'header', key: 'repo:repo-a', label: 'repo', count: 0, tone: '', repo }, + { + type: 'header', + key: 'project-group:child-a', + label: 'A child', + count: 0, + tone: '', + projectGroup: childA + }, + { + type: 'header', + key: 'project-group:root-b', + label: 'B', + count: 0, + tone: '', + projectGroup: rootB + } + ] as Row[] + + expect(getSidebarOrderedProjectGroupHeaderIdsByBucket(rows, groupsById)).toEqual( + new Map([ + ['root', ['root-a', 'root-b']], + ['parent:root-a', ['child-a']] + ]) + ) + }) +}) + +describe('mapSidebarProjectGroupDropIndexToSiblingInsertIndex', () => { + it('keeps upward drops at the same target index after removing the source', () => { + expect( + mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: 0, + sourceIndex: 2, + siblingCount: 2 + }) + ).toBe(0) + }) + + it('shifts downward drops because the source header is removed first', () => { + expect( + mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: 3, + sourceIndex: 0, + siblingCount: 2 + }) + ).toBe(2) + }) +}) + +describe('computeProjectGroupHeaderDropPreview', () => { + it('uses row-model header indices instead of mounted subset order', () => { + const preview = computeProjectGroupHeaderDropPreview({ + pointerY: 105, + containerTop: 0, + scrollTop: 0, + sidebarProjectGroupHeaderIds: ['a', 'b', 'c', 'd', 'e'], + rects: [ + { groupId: 'b', bucketKey: 'root', headerIndex: 1, top: 100, bottom: 128 }, + { groupId: 'c', bucketKey: 'root', headerIndex: 2, top: 200, bottom: 228 }, + { groupId: 'd', bucketKey: 'root', headerIndex: 3, top: 300, bottom: 328 } + ] + }) + + expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: 96 }) + }) +}) + +describe('getProjectGroupTabOrderForSidebarDrop', () => { + it('uses a midpoint between sibling tab orders when there is room', () => { + expect( + getProjectGroupTabOrderForSidebarDrop({ + siblings: [group('a', { tabOrder: 0 }), group('b', { tabOrder: 10 })], + dropIndex: 1 + }) + ).toBe(5) + }) + + it('assigns an order before the first sibling', () => { + expect( + getProjectGroupTabOrderForSidebarDrop({ + siblings: [group('a', { tabOrder: 10 }), group('b', { tabOrder: 20 })], + dropIndex: 0 + }) + ).toBe(9) + }) + + it('keeps a deterministic finite anchor when sibling orders collide', () => { + const tabOrder = getProjectGroupTabOrderForSidebarDrop({ + siblings: [group('a', { tabOrder: 10 }), group('b', { tabOrder: 10 })], + dropIndex: 1 + }) + + expect(tabOrder).toBe(11) + expect(Number.isFinite(tabOrder)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.ts b/src/renderer/src/components/sidebar/project-group-header-drop.ts new file mode 100644 index 00000000000..95546266779 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drop.ts @@ -0,0 +1,210 @@ +import { getWorktreeSidebarBoundaryDrop } from './worktree-sidebar-drag-autoscroll' +import type { Row } from './worktree-list-groups' +import type { ProjectGroup } from '../../../../shared/types' + +export type ProjectGroupHeaderDragBucketKey = string + +export type ProjectGroupHeaderDragRect = { + groupId: string + bucketKey: ProjectGroupHeaderDragBucketKey + // Index among sibling Project Group headers in the row model, not mounted DOM. + headerIndex: number + top: number + bottom: number +} + +export type ProjectGroupHeaderDropPreview = { + dropIndex: number + dropIndicatorY: number +} + +const INDICATOR_GAP_PX = 4 +const ROOT_PROJECT_GROUP_HEADER_BUCKET = 'root' + +type SidebarProjectGroupHeader = ProjectGroup | { id: null } | undefined + +function isConcreteProjectGroup( + projectGroup: SidebarProjectGroupHeader +): projectGroup is ProjectGroup { + return typeof projectGroup?.id === 'string' +} + +export function getProjectGroupHeaderDragBucketKey( + group: Pick, + projectGroupById?: ReadonlyMap +): ProjectGroupHeaderDragBucketKey { + const parentGroupId = group.parentGroupId ?? null + if (!parentGroupId) { + return ROOT_PROJECT_GROUP_HEADER_BUCKET + } + if (projectGroupById && !projectGroupById.has(parentGroupId)) { + return ROOT_PROJECT_GROUP_HEADER_BUCKET + } + return `parent:${parentGroupId}` +} + +export function getSidebarOrderedProjectGroupHeaderIdsByBucket( + rows: readonly Row[], + projectGroupById?: ReadonlyMap +): Map { + const buckets = new Map() + for (const row of rows) { + if (row.type !== 'header' || row.repo || !isConcreteProjectGroup(row.projectGroup)) { + continue + } + const bucketKey = getProjectGroupHeaderDragBucketKey(row.projectGroup, projectGroupById) + const list = buckets.get(bucketKey) ?? [] + list.push(row.projectGroup.id) + buckets.set(bucketKey, list) + } + return buckets +} + +export function getProjectGroupTabOrderForSidebarDrop(args: { + siblings: readonly ProjectGroup[] + dropIndex: number +}): number { + const ordered = args.siblings.slice() + if (ordered.length === 0) { + return 0 + } + const getOrder = (group: ProjectGroup | undefined): number | undefined => + group && Number.isFinite(group.tabOrder) ? group.tabOrder : undefined + const before = getOrder(ordered[args.dropIndex - 1]) + const after = getOrder(ordered[args.dropIndex]) + if (before === undefined && after === undefined) { + return 0 + } + if (before === undefined) { + return after !== undefined ? after - 1 : 0 + } + if (after === undefined) { + return before + 1 + } + if (after > before) { + const midpoint = before + (after - before) / 2 + return Number.isFinite(midpoint) ? midpoint : before / 2 + after / 2 + } + // Why: duplicate legacy ranks leave no slot between siblings; a finite anchor + // lets the next drag establish a stable persisted order. + return before + 1 +} + +export function mapSidebarProjectGroupDropIndexToSiblingInsertIndex(args: { + sidebarDropIndex: number + sourceIndex: number + siblingCount: number +}): number { + // Why: sidebar drop indices include the dragged header, but tabOrder is + // computed against the sibling list after that header is removed. + const adjustedDropIndex = + args.sourceIndex >= 0 && args.sidebarDropIndex > args.sourceIndex + ? args.sidebarDropIndex - 1 + : args.sidebarDropIndex + return Math.max(0, Math.min(args.siblingCount, adjustedDropIndex)) +} + +function getVirtualRowStart(virtualRow: HTMLElement | null): number | null { + if (!virtualRow) { + return null + } + const rawStart = virtualRow.getAttribute('data-worktree-virtual-row-start') + if (rawStart === null) { + return null + } + const start = Number(rawStart) + return Number.isFinite(start) ? start : null +} + +export function measureProjectGroupHeaderDragRects( + container: HTMLElement, + bucketKey?: ProjectGroupHeaderDragBucketKey +): ProjectGroupHeaderDragRect[] { + const containerRect = container.getBoundingClientRect() + const rects: ProjectGroupHeaderDragRect[] = [] + container.querySelectorAll('[data-project-group-header-id]').forEach((element) => { + const groupId = element.getAttribute('data-project-group-header-id') + const elementBucketKey = element.getAttribute('data-project-group-header-bucket') + const rawHeaderIndex = element.getAttribute('data-project-group-header-index') + const headerIndex = rawHeaderIndex === null ? Number.NaN : Number(rawHeaderIndex) + if (!groupId || !elementBucketKey || !Number.isFinite(headerIndex)) { + return + } + if (bucketKey !== undefined && elementBucketKey !== bucketKey) { + return + } + const rect = element.getBoundingClientRect() + const virtualRow = element.closest('[data-worktree-virtual-row]') + const virtualRowStart = getVirtualRowStart(virtualRow) + const top = + virtualRow && virtualRowStart !== null + ? virtualRowStart + rect.top - virtualRow.getBoundingClientRect().top + : rect.top - containerRect.top + container.scrollTop + rects.push({ + groupId, + bucketKey: elementBucketKey, + headerIndex, + top, + bottom: top + rect.height + }) + }) + rects.sort((left, right) => left.top - right.top) + return rects +} + +export function computeProjectGroupHeaderDropPreview(args: { + pointerY: number + containerTop: number + scrollTop: number + rects: readonly ProjectGroupHeaderDragRect[] + sidebarProjectGroupHeaderIds: readonly string[] +}): ProjectGroupHeaderDropPreview | null { + const { rects, sidebarProjectGroupHeaderIds } = args + if (rects.length === 0 || sidebarProjectGroupHeaderIds.length === 0) { + return null + } + + const localY = args.pointerY - args.containerTop + args.scrollTop + const first = rects[0]! + const last = rects.at(-1)! + const boundaryDrop = getWorktreeSidebarBoundaryDrop({ + localY, + firstRect: { + worktreeId: first.groupId, + groupIndex: first.headerIndex, + top: first.top, + bottom: first.bottom + }, + lastRect: { + worktreeId: last.groupId, + groupIndex: last.headerIndex, + top: last.top, + bottom: last.bottom + }, + sourceGroupSize: sidebarProjectGroupHeaderIds.length + }) + if (boundaryDrop.kind === 'outside') { + return null + } + + let dropIndex = last.headerIndex + 1 + let indicatorY = last.bottom + INDICATOR_GAP_PX + if (boundaryDrop.kind === 'drop') { + dropIndex = boundaryDrop.dropIndex + indicatorY = boundaryDrop.indicatorY + } else { + for (const rect of rects) { + const mid = (rect.top + rect.bottom) / 2 + if (localY < mid) { + dropIndex = rect.headerIndex + indicatorY = Math.max(0, rect.top - INDICATOR_GAP_PX) + break + } + } + } + + return { + dropIndex, + dropIndicatorY: Math.max(args.scrollTop, indicatorY) + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 805aaa4c6de..f0ef24b9ef6 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -2275,6 +2275,67 @@ describe('project groups', () => { ]) }) + it('orders Project Group siblings by tabOrder within each parent bucket', () => { + const rootA: ProjectGroup = { + id: 'group-root-a', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 20, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const rootB: ProjectGroup = { + ...rootA, + id: 'group-root-b', + name: 'Infrastructure', + tabOrder: 10 + } + const childLate: ProjectGroup = { + ...rootA, + id: 'group-child-late', + name: 'late', + parentGroupId: rootB.id, + tabOrder: 30 + } + const childEarly: ProjectGroup = { + ...rootA, + id: 'group-child-early', + name: 'early', + parentGroupId: rootB.id, + tabOrder: 5 + } + + const rows = buildRows( + 'repo', + [], + new Map(), + null, + new Set(), + undefined, + undefined, + 'recent', + {}, + undefined, + false, + undefined, + [rootA, rootB, childLate, childEarly] + ) + + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:group-root-b', + 'project-group:group-child-early', + 'project-group:group-child-late', + 'project-group:group-root-a' + ]) + expect(rows.filter((row) => row.type === 'header').map((row) => row.projectGroupDepth)).toEqual( + [0, 1, 1, 0] + ) + }) + it('renders nested Project Groups before repos assigned to their leaf group', () => { const rootGroup: ProjectGroup = { id: 'group-root', From d65c938529bd9a893b7fda725743cb72067729e6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:42:49 -0700 Subject: [PATCH 51/70] Keep runtime mirror sized with desktop terminal resizes (#7192) Co-authored-by: Orca --- src/main/ipc/pty.test.ts | 56 ++++++++++ src/main/runtime/orca-runtime.test.ts | 143 ++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 27 +++-- 3 files changed, 218 insertions(+), 8 deletions(-) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index e3475692814..26c1bda9157 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3829,6 +3829,62 @@ describe('registerPtyHandlers', () => { const reported = await handlers.get('pty:getSize')!(null, { id }) expect(reported).toEqual({ cols: 100, rows: 30 }) }) + + it('fans out accepted desktop resizes to the runtime after provider resize', async () => { + const resize = vi.fn() + setupProviderWithAppliedSize({ applied: { cols: 120, rows: 30 }, resize }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + isResizeSuppressed: vi.fn(() => false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + onExternalPtyResize: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + + resizeListener()(mainWindowIpcEvent, { id, cols: 120, rows: 30 }) + + expect(resize).toHaveBeenCalledWith(id, 120, 30) + expect(runtime.onExternalPtyResize).toHaveBeenCalledWith(id, 120, 30) + expect(resize.mock.invocationCallOrder[0]).toBeLessThan( + runtime.onExternalPtyResize.mock.invocationCallOrder[0]! + ) + }) + + it('does not fan out rejected desktop resizes to the runtime', async () => { + setupProviderWithAppliedSize({ + applied: { cols: 80, rows: 24 }, + resize: () => { + throw new Error('resize rejected') + } + }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + isResizeSuppressed: vi.fn(() => false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + onExternalPtyResize: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + + resizeListener()(mainWindowIpcEvent, { id, cols: 120, rows: 30 }) + + expect(runtime.onExternalPtyResize).not.toHaveBeenCalled() + }) }) it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b4b2dae941d..372cf304061 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -52,6 +52,7 @@ import { recentTerminalOutputIncludesPath, type RuntimeTerminalAgentStatusEvent } from './orca-runtime' +import { HeadlessEmulator } from '../daemon/headless-emulator' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, @@ -719,6 +720,64 @@ function syncSinglePty( }) } +function makeDeferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function makeStatusFrame(index: number, first: boolean): string { + const pad = '·'.repeat(60) + const rows = [ + `✻ 执行任务中… (esc to interrupt) [${index}] ${pad}`, + ` ⎿ 正在分析代码库结构与依赖关系,请稍候… ${pad}`, + ` ⎿ tokens: ${1000 + index * 137} · elapsed: ${index}s ${pad}` + ] + return `${first ? '' : '\x1b[2A'}\r${rows.map((row) => `\x1b[K${row}`).join('\r\n')}\r` +} + +async function writeHeadless(emulator: HeadlessEmulator, data: string): Promise { + await emulator.write(data) +} + +function visibleNonEmptyLines(emulator: HeadlessEmulator): string[] { + return emulator.getVisibleLines().filter((line) => line.length > 0) +} + +async function parseHeadlessSnapshotLines( + snapshot: { data: string; cols: number; rows: number }, + display: { cols: number; rows: number } +): Promise { + const restored = new HeadlessEmulator({ cols: display.cols, rows: display.rows }) + try { + restored.resize(snapshot.cols, snapshot.rows) + await writeHeadless(restored, `\x1b[2J\x1b[3J\x1b[H${snapshot.data}`) + restored.resize(display.cols, display.rows) + return visibleNonEmptyLines(restored) + } finally { + restored.dispose() + } +} + +async function referenceStatusFrameLines( + spawn: { cols: number; rows: number }, + resized: { cols: number; rows: number } +): Promise { + const truth = new HeadlessEmulator({ cols: spawn.cols, rows: spawn.rows }) + try { + await writeHeadless(truth, 'user@host % claude\r\n') + truth.resize(resized.cols, resized.rows) + for (let index = 0; index < 5; index += 1) { + await writeHeadless(truth, makeStatusFrame(index, index === 0)) + } + return visibleNonEmptyLines(truth) + } finally { + truth.dispose() + } +} + const TEST_WINDOW_ID = 1 const TEST_REPO_ID = 'repo-1' const TEST_REPO_PATH = '/tmp/repo' @@ -5411,6 +5470,90 @@ describe('OrcaRuntimeService', () => { }) }) + it('resizes the headless mirror after an accepted desktop PTY resize', async () => { + const spawn = { cols: 80, rows: 24 } + const resized = { cols: 120, rows: 30 } + let currentSize = spawn + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => currentSize + }) + syncSinglePty(runtime, 'pty-1') + + runtime.onPtyData('pty-1', 'user@host % claude\r\n', 100) + currentSize = resized + runtime.onExternalPtyResize('pty-1', resized.cols, resized.rows) + for (let index = 0; index < 5; index += 1) { + runtime.onPtyData('pty-1', makeStatusFrame(index, index === 0), 200 + index) + } + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 5000 }) + expect(snapshot).toMatchObject({ cols: resized.cols, rows: resized.rows, source: 'headless' }) + await expect(parseHeadlessSnapshotLines(snapshot!, resized)).resolves.toEqual( + await referenceStatusFrameLines(spawn, resized) + ) + }) + + it('orders headless mirror resizes behind queued PTY writes', async () => { + const spawn = { cols: 80, rows: 10 } + const resized = { cols: 120, rows: 10 } + let currentSize = spawn + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => currentSize, + resize: () => { + currentSize = resized + return true + } + }) + syncSinglePty(runtime, 'pty-1') + runtime.onPtyData('pty-1', 'prompt\r\n', 100) + await runtime.serializeMainTerminalBuffer('pty-1') + + type HeadlessStateForTest = { + emulator: HeadlessEmulator + writeChain: Promise + } + const headless = ( + runtime as unknown as { headlessTerminals: Map } + ).headlessTerminals.get('pty-1') + expect(headless).toBeDefined() + const originalWrite = headless!.emulator.write.bind(headless!.emulator) + const queuedWriteStarted = makeDeferred() + const releaseQueuedWrite = makeDeferred() + headless!.emulator.write = async (data: string): Promise => { + queuedWriteStarted.resolve() + await releaseQueuedWrite.promise + await originalWrite(data) + } + + try { + runtime.onPtyData('pty-1', '\x1b[90GOLD', 200) + await queuedWriteStarted.promise + await runtime.updateDesktopViewport('pty-1', resized) + runtime.onPtyData('pty-1', '\r\nNEXT', 300) + releaseQueuedWrite.resolve() + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 100 }) + expect(snapshot).toMatchObject({ cols: resized.cols, rows: resized.rows }) + await expect(parseHeadlessSnapshotLines(snapshot!, resized)).resolves.toEqual([ + 'prompt', + ' O', + 'LD', + 'NEXT' + ]) + } finally { + headless!.emulator.write = originalWrite + releaseQueuedWrite.resolve() + } + }) + it('adopts renderer-seeded titles into headless main terminal snapshots', async () => { const artifactPath = '/tmp/renderer-seeded-artifact.json' const serializeBuffer = vi.fn().mockResolvedValue({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 81150a3a36b..afd10bee579 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -5760,7 +5760,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 +7066,6 @@ export class OrcaRuntimeService { if (!resized) { return false } - this.resizeHeadlessTerminal(ptyId, cols, rows) this.onExternalPtyResize(ptyId, cols, rows) return true } @@ -7880,12 +7893,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 +7915,7 @@ export class OrcaRuntimeService { if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) { return } + this.resizeHeadlessTerminal(ptyId, cols, rows) this.refreshRendererGeometry(ptyId, cols, rows) } From bf6a39987cfcf64ea71e4c4fa2cb178f7b958b75 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 05:50:01 +0000 Subject: [PATCH 52/70] release: v1.4.120-rc.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 41ec21ecb50..5e37d338f40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120-rc.4", + "version": "1.4.120-rc.5", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From fa94065a81efbd0f0ea24133078b7ffec23c97d5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:06:32 -0700 Subject: [PATCH 53/70] Recover exported terminal handles from live PTYs (#7162) * Recover exported terminal handles from live PTYs * Guard terminal-handle adoption: first-wins, no collisions Discovery adoption of ORCA_TERMINAL_HANDLE is now skipped when the pty already has a handle bound this session (re-keying would strand waiters registered under the old handle) or when the reported handle is already bound to a different pty (provider-reported values are not trusted to be collision-free). Also adds the relay why-comment and PtyProcessSummary type alias from review feedback. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/daemon/daemon-pty-adapter.ts | 12 +- src/main/daemon/daemon-pty-router.ts | 9 +- .../daemon/degraded-daemon-pty-provider.ts | 9 +- src/main/daemon/session.ts | 3 + src/main/daemon/terminal-host.ts | 2 + src/main/daemon/types.ts | 1 + src/main/providers/local-pty-provider.ts | 12 +- src/main/providers/ssh-pty-provider.ts | 6 +- src/main/providers/types.ts | 10 +- src/main/runtime/orca-runtime.test.ts | 106 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 45 +++++++- src/relay/pty-handler.ts | 34 +++++- 12 files changed, 228 insertions(+), 21 deletions(-) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index a649abebcc9..56a775a8c99 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -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' @@ -517,7 +522,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { alive, killed } } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { await this.ensureConnected() const result = await this.client.request('listSessions', undefined) return result.sessions @@ -525,7 +530,8 @@ export class DaemonPtyAdapter implements IPtyProvider { .map((s) => ({ id: s.sessionId, cwd: s.cwd ?? '', - title: 'shell' + title: 'shell', + ...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {}) })) } diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 2212635d1fa..1f80f770cd9 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,5 +1,10 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter @@ -124,7 +129,7 @@ export class DaemonPtyRouter implements IPtyProvider { await this.current.revive(state) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { // Why: runtime exact-stop/liveness flows must fail closed if any adapter // cannot provide a trustworthy process list. const results = await Promise.all(this.allAdapters().map((adapter) => adapter.listProcesses())) diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index acf95ffcdfe..a7b8c68fa19 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -1,5 +1,10 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' type ManagedPtyProvider = IPtyProvider & { disconnectOnly?: () => Promise @@ -135,7 +140,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { await this.fallback.revive(state) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { const results = await Promise.all( this.allProviders().map((provider) => provider.listProcesses()) ) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 551b8ad276c..b4d3433a5f8 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -56,6 +56,7 @@ export type SessionOptions = { sessionId: string cols: number rows: number + terminalHandle?: string subprocess: SubprocessHandle shellReadySupported: boolean shellReadyTimeoutMs?: number @@ -76,6 +77,7 @@ type AttachedClient = { export class Session { readonly sessionId: string + readonly terminalHandle: string | null private _state: SessionState = 'running' private _shellState: ShellReadyState private _exitCode: number | null = null @@ -97,6 +99,7 @@ export class Session { constructor(opts: SessionOptions) { this.sessionId = opts.sessionId + this.terminalHandle = opts.terminalHandle ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit const size = normalizePtySize(opts.cols, opts.rows) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 4731322e526..41e1f96b1bb 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -136,6 +136,7 @@ export class TerminalHost { sessionId: opts.sessionId, cols: size.cols, rows: size.rows, + terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, subprocess, shellReadySupported: opts.shellReadySupported ?? false, // Why: reap the dead session (dispose emulator + drop from the map) the @@ -301,6 +302,7 @@ export class TerminalHost { state: session.state, shellState: session.shellState, isAlive: true, + ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), pid: session.pid, cwd: session.getCwd(), cols: size?.cols ?? 0, diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 868afa81610..a38cc21a425 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -324,6 +324,7 @@ export type SessionInfo = { state: SessionState shellState: ShellReadyState isAlive: boolean + terminalHandle?: string pid: number | null cwd: string | null cols: number diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 01ea3aeea57..7e8588a6b64 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -21,7 +21,7 @@ import { updateHistFileForFallback, logHistoryInjection } from '../terminal-history' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { ensureNodePtySpawnHelperExecutable, validateWorkingDirectory, @@ -67,6 +67,7 @@ let ptyCounter = 0 const ptyProcesses = new Map() const ptyShellName = new Map() const ptyAgentForegroundContextPaths = new Map() +const ptyTerminalHandle = new Map() // Why: node-pty's onData/onExit register native NAPI ThreadSafeFunction // callbacks. If the PTY is killed without disposing these listeners, the // stale callbacks survive into node::FreeEnvironment() where NAPI attempts @@ -187,6 +188,7 @@ function clearPtyState(id: string): void { ptyProcesses.delete(id) ptyShellName.delete(id) ptyAgentForegroundContextPaths.delete(id) + ptyTerminalHandle.delete(id) ptyLoadGeneration.delete(id) } @@ -680,6 +682,9 @@ export class LocalPtyProvider implements IPtyProvider { const proc = spawnResult.process ptyProcesses.set(id, proc) ptyShellName.set(id, getSpawnedShellName(shellPath)) + if (finalEnv.ORCA_TERMINAL_HANDLE) { + ptyTerminalHandle.set(id, finalEnv.ORCA_TERMINAL_HANDLE) + } ptyAgentForegroundContextPaths.set( id, getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId }) @@ -945,11 +950,12 @@ export class LocalPtyProvider implements IPtyProvider { /* re-spawning handles local revival */ } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ id, cwd: '', - title: proc.process || ptyShellName.get(id) || 'shell' + title: proc.process || ptyShellName.get(id) || 'shell', + ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}) })) } diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 7284f2353ba..398cc935142 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -1,5 +1,5 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env' @@ -254,9 +254,9 @@ export class SshPtyProvider implements IPtyProvider { await this.mux.request('pty.revive', { state }) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { const result = await this.mux.request('pty.listProcesses') - return (result as { id: string; cwd: string; title: string }[]).map((session) => ({ + return (result as PtyProcessInfo[]).map((session) => ({ ...session, id: this.toAppPtyId(session.id) })) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index a7516ec4073..ec9061c5130 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -99,6 +99,14 @@ export type PtySpawnResult = { } } +export type PtyProcessInfo = { + id: string + cwd: string + title: string + /** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */ + terminalHandle?: string +} + export type IPtyProvider = { spawn(opts: PtySpawnOptions): Promise attach(id: string): Promise @@ -128,7 +136,7 @@ export type IPtyProvider = { getForegroundProcess(id: string): Promise serialize(ids: string[]): Promise revive(state: string): Promise - listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> + listProcesses(): Promise getDefaultShell(): Promise getProfiles(): Promise<{ name: string; path: string }[]> onData(callback: (payload: { id: string; data: string }) => void): () => void diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 372cf304061..ee8d6edd608 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -10497,6 +10497,112 @@ describe('OrcaRuntimeService', () => { expect(read.tail).toEqual(['ready']) }) + it('recovers exported ORCA_TERMINAL_HANDLE from discovered live PTY sessions', async () => { + const runtime = new OrcaRuntimeService(store) + const writes: string[] = [] + runtime.setPtyController({ + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-1', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_exported' + } + ] + }) + + const listed = await runtime.listTerminals() + expect(listed.terminals[0]?.handle).toBe('term_exported') + + runtime.onPtyData('pty-1', 'after restart\n', 100) + await expect(runtime.readTerminal('term_exported')).resolves.toMatchObject({ + handle: 'term_exported', + tail: ['after restart'] + }) + await expect( + runtime.sendTerminal('term_exported', { text: 'still writable' }) + ).resolves.toMatchObject({ + handle: 'term_exported', + accepted: true + }) + expect(writes).toEqual(['still writable']) + }) + + it('does not adopt a discovered terminal handle already bound to another live PTY', async () => { + const runtime = new OrcaRuntimeService(store) + const writesByPty = new Map() + runtime.setPtyController({ + write: (ptyId, data) => { + writesByPty.set(ptyId, [...(writesByPty.get(ptyId) ?? []), data]) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-victim', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_victim' + }, + { + id: 'pty-imposter', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_victim' + } + ] + }) + + const listed = await runtime.listTerminals() + const handles = listed.terminals.map((terminal) => terminal.handle) + expect(handles).toContain('term_victim') + expect(new Set(handles).size).toBe(handles.length) + + await expect( + runtime.sendTerminal('term_victim', { text: 'for victim' }) + ).resolves.toMatchObject({ accepted: true }) + expect(writesByPty.get('pty-victim')).toEqual(['for victim']) + expect(writesByPty.has('pty-imposter')).toBe(false) + }) + + it('keeps an already-bound terminal handle when discovery reports a different exported one', async () => { + const runtime = new OrcaRuntimeService(store) + const writes: string[] = [] + runtime.setPtyController({ + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-1', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_from_env' + } + ] + }) + runtime.registerPreAllocatedHandleForPty('pty-1', 'term_already_bound') + + const listed = await runtime.listTerminals() + expect(listed.terminals[0]?.handle).toBe('term_already_bound') + await expect( + runtime.sendTerminal('term_already_bound', { text: 'still routed' }) + ).resolves.toMatchObject({ accepted: true }) + expect(writes).toEqual(['still routed']) + // the reported-but-not-adopted handle must not resolve to the live pty + await expect(runtime.readTerminal('term_from_env')).rejects.toThrow() + }) + it('binds advertised URLs for renderer-restored PTYs that skip registerPty', () => { const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index afd10bee579..38414da2155 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -661,7 +661,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, @@ -1037,7 +1037,7 @@ type RuntimePtyController = { hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean - listProcesses?(): Promise<{ id: string; cwd: string; title: string }[]> + listProcesses?(): Promise serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } @@ -5041,6 +5041,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) { @@ -17789,6 +17829,7 @@ export class OrcaRuntimeService { const sessions = sessionsResult.value const livePtyIds = new Set(sessions.map((session) => session.id)) for (const session of sessions) { + this.adoptControllerTerminalHandle(session.id, session.terminalHandle) const worktreeId = inferWorktreeIdFromPtyId(session.id) ?? findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 5f4fff86610..126f761333b 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -62,6 +62,7 @@ type ManagedPty = { paneKey?: string tabId?: string worktreeId?: string + terminalHandle?: string startupCommand?: ManagedStartupCommand } @@ -156,6 +157,13 @@ function resolvePtyShellOverride(shellOverride: string): string { return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride } +type PtyProcessSummary = { + id: string + cwd: string + title: string + terminalHandle?: string +} + type SerializedPtyEntry = { id: string pid: number @@ -165,6 +173,7 @@ type SerializedPtyEntry = { paneKey?: string tabId?: string worktreeId?: string + terminalHandle?: string } export type PtyExitListener = (event: { id: string; paneKey?: string }) => void @@ -539,6 +548,10 @@ export class PtyHandler { // for overlay resolution; runtime-owned PTYs opt into relay delivery // because no renderer TerminalPane exists to type the command. const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined + // Why: kept so a restarted runtime can re-adopt this live PTY under its + // originally-exported handle (reported via listProcesses, survives revive). + const terminalHandle = + typeof env?.ORCA_TERMINAL_HANDLE === 'string' ? env.ORCA_TERMINAL_HANDLE : undefined const command = typeof params.command === 'string' ? params.command : undefined const terminalWindowsWslDistro = typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null @@ -589,6 +602,7 @@ export class PtyHandler { paneKey, tabId, worktreeId, + ...(terminalHandle ? { terminalHandle } : {}), ...(shouldProviderDeliverCommand ? { startupCommand: { @@ -819,12 +833,17 @@ export class PtyHandler { return await getForegroundProcessName(managed.pty.pid, managed.pty.process || null) } - private async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { - const results: { id: string; cwd: string; title: string }[] = [] + private async listProcesses(): Promise { + const results: PtyProcessSummary[] = [] for (const [id, managed] of this.ptys) { const title = (await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)) || 'shell' - results.push({ id, cwd: managed.initialCwd, title }) + results.push({ + id, + cwd: managed.initialCwd, + title, + ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) + }) } return results } @@ -846,7 +865,8 @@ export class PtyHandler { cwd: managed.initialCwd, paneKey: managed.paneKey, tabId: managed.tabId, - worktreeId: managed.worktreeId + worktreeId: managed.worktreeId, + ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) } return JSON.stringify(entries) @@ -884,6 +904,9 @@ export class PtyHandler { if (entry.worktreeId) { revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId } + if (entry.terminalHandle) { + revivedEnv.ORCA_TERMINAL_HANDLE = entry.terminalHandle + } const shell = resolveDefaultShell() // Why: `command` is intentionally absent from this revive path because // SerializedPtyEntry (see line 99) does not persist it — ManagedPty @@ -914,7 +937,8 @@ export class PtyHandler { buffered: '', paneKey: entry.paneKey, tabId: entry.tabId, - worktreeId: entry.worktreeId + worktreeId: entry.worktreeId, + ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) }) // Why: nextId starts at 1 and is only incremented by spawn(). Revived From f0278c116e9d3abe66dce26364b4e1122f613b76 Mon Sep 17 00:00:00 2001 From: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:22:30 -0300 Subject: [PATCH 54/70] fix(mobile): preserve terminal input mode on reentry (#7129) Fixes #6972.\n\nPreserves mobile terminal buffered/live input mode across Android terminal re-entry and session refreshes. Includes follow-up hardening for pre-hydration preference edits and failed storage reads. --- .../app/h/[hostId]/session/[worktreeId].tsx | 80 +------ ...erminal-live-input-mode-preference.test.ts | 138 ++++++++++++ ...use-terminal-live-input-mode-preference.ts | 212 ++++++++++++++++++ mobile/src/storage/preferences.test.ts | 59 +++++ mobile/src/storage/preferences.ts | 47 ++++ .../src/terminal/terminal-live-input.test.ts | 31 +++ mobile/src/terminal/terminal-live-input.ts | 42 ++++ 7 files changed, 540 insertions(+), 69 deletions(-) create mode 100644 mobile/src/session/use-terminal-live-input-mode-preference.test.ts create mode 100644 mobile/src/session/use-terminal-live-input-mode-preference.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 3feb30faa3d..a934334b8f2 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -96,9 +96,7 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target' import { clearTerminalLiveInputFocusTimer, - defaultTerminalLiveInputHandles, isTerminalLiveInputWithinByteLimit, - pruneTerminalLiveInputHandles, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' @@ -165,6 +163,7 @@ import { } from '../../../../src/session/mobile-new-tab-agent-options' import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment' import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste' +import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference' import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions' import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind' @@ -896,11 +895,14 @@ export default function SessionScreen() { const [terminalLinkOpenMode, setTerminalLinkOpenMode] = useState('orca-browser') const [liveInputCapture, setLiveInputCapture] = useState('') - const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( - () => new Set() - ) - const liveInputTerminalHandlesRef = useRef>(new Set()) - const defaultedLiveInputTerminalHandlesRef = useRef>(new Set()) + const { + clearTerminalLiveInputDefault, + defaultTerminalHandlesToLiveInput, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + pruneTerminalHandlesFromLiveInput, + toggleTerminalLiveInput + } = useTerminalLiveInputModePreference({ hostId, worktreeId }) const [activeHandle, setActiveHandle] = useState(null) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) @@ -1104,7 +1106,6 @@ export default function SessionScreen() { sessionTabsRef.current = sessionTabs activeSessionTabIdRef.current = activeSessionTabId markdownDocsRef.current = markdownDocs - liveInputTerminalHandlesRef.current = liveInputTerminalHandles const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState( createWarningState, initialCreateWarning @@ -1170,52 +1171,6 @@ export default function SessionScreen() { [clearToastHideTimer] ) - // Why: direct input is now the mobile default, but only once per discovered - // handle so a user's buffered-mode toggle survives tab/list refreshes. - const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => { - const result = defaultTerminalLiveInputHandles( - liveInputTerminalHandlesRef.current, - defaultedLiveInputTerminalHandlesRef.current, - handles - ) - if (!result.changed) { - return - } - const nextEnabledHandles = new Set(result.enabledHandles) - const nextDefaultedHandles = new Set(result.defaultedHandles) - liveInputTerminalHandlesRef.current = nextEnabledHandles - defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles - setLiveInputTerminalHandles(nextEnabledHandles) - }, []) - - const pruneTerminalHandlesFromLiveInput = useCallback((liveHandles: ReadonlySet) => { - const result = pruneTerminalLiveInputHandles( - liveInputTerminalHandlesRef.current, - defaultedLiveInputTerminalHandlesRef.current, - liveHandles - ) - if (!result.changed) { - return - } - const nextEnabledHandles = new Set(result.enabledHandles) - const nextDefaultedHandles = new Set(result.defaultedHandles) - liveInputTerminalHandlesRef.current = nextEnabledHandles - defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles - setLiveInputTerminalHandles(nextEnabledHandles) - }, []) - - const clearTerminalLiveInputDefault = useCallback( - (handle: string) => { - const liveHandles = new Set([ - ...liveInputTerminalHandlesRef.current, - ...defaultedLiveInputTerminalHandlesRef.current - ]) - liveHandles.delete(handle) - pruneTerminalHandlesFromLiveInput(liveHandles) - }, - [pruneTerminalHandlesFromLiveInput] - ) - const dictation = useMobileDictation({ client, enabled: canSend, @@ -2685,9 +2640,6 @@ export default function SessionScreen() { setSessionTabs([]) setActiveSessionTabId(null) clearPendingLiveInputCommit() - liveInputTerminalHandlesRef.current = new Set() - defaultedLiveInputTerminalHandlesRef.current = new Set() - setLiveInputTerminalHandles(new Set()) setMarkdownDocs(new Map()) setFileDocs(new Map()) clearDelayedActionTimers() @@ -3362,17 +3314,7 @@ export default function SessionScreen() { if (!activeHandle) { return } - const nextEnabled = !liveInputTerminalHandles.has(activeHandle) - setLiveInputTerminalHandles((prev) => { - const next = new Set(prev) - if (nextEnabled) { - next.add(activeHandle) - } else { - next.delete(activeHandle) - } - liveInputTerminalHandlesRef.current = next - return next - }) + const nextEnabled = toggleTerminalLiveInput(activeHandle) clearPendingLiveInputCommit() if (nextEnabled) { scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) @@ -3380,7 +3322,7 @@ export default function SessionScreen() { clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef) liveInputRef.current?.blur() } - }, [activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles]) + }, [activeHandle, clearPendingLiveInputCommit, toggleTerminalLiveInput]) const allowTerminalGestureInput = useCallback( (handle: string, sequenceCount: number): boolean => { diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.test.ts b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts new file mode 100644 index 00000000000..d95fab23698 --- /dev/null +++ b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts @@ -0,0 +1,138 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles, + type DisabledTerminalLiveInputHandlesPreference +} from '../storage/preferences' +import { useTerminalLiveInputModePreference } from './use-terminal-live-input-mode-preference' + +vi.mock('../storage/preferences', () => ({ + readDisabledTerminalLiveInputHandlesPreference: vi.fn(), + saveDisabledTerminalLiveInputHandles: vi.fn() +})) + +type TerminalLiveInputModePreferenceHarness = { + readonly current: ReturnType + readonly unmount: () => void +} + +type Deferred = { + readonly promise: Promise + readonly resolve: (value: T) => void +} + +function createDeferred(): Deferred { + let resolve: ((value: T) => void) | null = null + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + if (!resolve) { + throw new Error('deferred resolver was not initialized') + } + return { promise, resolve } +} + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness { + let current: ReturnType | null = null + let renderer: ReactTestRenderer | null = null + + function Harness(): null { + current = useTerminalLiveInputModePreference({ + hostId: 'host-1', + worktreeId: 'worktree-1' + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(Harness)) + }) + } finally { + restoreConsoleError() + } + if (!current || !renderer) { + throw new Error('terminal live input mode preference hook did not render') + } + + return { + get current() { + if (!current) { + throw new Error('terminal live input mode preference hook is not mounted') + } + return current + }, + unmount: () => { + act(() => renderer?.unmount()) + } + } +} + +describe('terminal live input mode preference hook', () => { + beforeEach(() => { + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReset() + vi.mocked(saveDisabledTerminalLiveInputHandles).mockReset() + vi.mocked(saveDisabledTerminalLiveInputHandles).mockResolvedValue() + }) + + it('merges pre-hydration edits with loaded disabled handles', async () => { + const load = createDeferred() + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise) + const harness = createTerminalLiveInputModePreferenceHarness() + + act(() => { + harness.current.defaultTerminalHandlesToLiveInput(['pty-1', 'pty-2']) + }) + act(() => { + expect(harness.current.toggleTerminalLiveInput('pty-1')).toBe(true) + }) + + await act(async () => { + load.resolve({ handles: new Set(['pty-2']), loaded: true }) + await load.promise + }) + + expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1']) + expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledTimes(1) + expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledWith( + 'host-1', + 'worktree-1', + new Set(['pty-2']) + ) + harness.unmount() + }) + + it('does not persist fallback-empty storage reads during clean hydration', async () => { + const load = createDeferred() + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise) + const harness = createTerminalLiveInputModePreferenceHarness() + + act(() => { + harness.current.defaultTerminalHandlesToLiveInput(['pty-1']) + }) + + await act(async () => { + load.resolve({ handles: new Set(), loaded: false }) + await load.promise + }) + + expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1']) + expect(saveDisabledTerminalLiveInputHandles).not.toHaveBeenCalled() + harness.unmount() + }) +}) diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.ts b/mobile/src/session/use-terminal-live-input-mode-preference.ts new file mode 100644 index 00000000000..dd59449b816 --- /dev/null +++ b/mobile/src/session/use-terminal-live-input-mode-preference.ts @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles +} from '../storage/preferences' +import { + applyDisabledTerminalLiveInputHandles, + defaultTerminalLiveInputHandles, + filterTerminalLiveInputDefaultCandidates, + pruneTerminalLiveInputHandles +} from '../terminal/terminal-live-input' + +type UseTerminalLiveInputModePreferenceOptions = { + readonly hostId: string + readonly worktreeId: string +} + +export function useTerminalLiveInputModePreference({ + hostId, + worktreeId +}: UseTerminalLiveInputModePreferenceOptions) { + const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( + () => new Set() + ) + const liveInputTerminalHandlesRef = useRef>(new Set()) + const defaultedLiveInputTerminalHandlesRef = useRef>(new Set()) + const disabledLiveInputTerminalHandlesRef = useRef>(new Set()) + const disabledLiveInputHydratedRef = useRef(false) + const pendingDisabledLiveInputHydrationEditsRef = useRef>(new Map()) + const pendingLiveInputDefaultHandlesRef = useRef>(new Set()) + + const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => { + // Why: terminal discovery (tab snapshots, list poll, create) can arrive + // before the async persisted-disabled load on worktree re-entry. + if (!disabledLiveInputHydratedRef.current) { + for (const handle of handles) { + pendingLiveInputDefaultHandlesRef.current.add(handle) + } + return + } + const defaultableHandles = filterTerminalLiveInputDefaultCandidates( + handles, + disabledLiveInputTerminalHandlesRef.current + ) + const result = defaultTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + defaultableHandles + ) + if (!result.changed) { + return + } + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + }, []) + + const persistDisabledLiveInputHandles = useCallback(() => { + void saveDisabledTerminalLiveInputHandles( + hostId, + worktreeId, + disabledLiveInputTerminalHandlesRef.current + ).catch(() => {}) + }, [hostId, worktreeId]) + + const pruneTerminalHandlesFromLiveInput = useCallback( + (liveHandles: ReadonlySet) => { + const result = pruneTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + liveHandles + ) + let prunedDisabledHandles = false + for (const handle of disabledLiveInputTerminalHandlesRef.current) { + if (liveHandles.has(handle)) { + continue + } + disabledLiveInputTerminalHandlesRef.current.delete(handle) + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false) + } + prunedDisabledHandles = true + } + if (prunedDisabledHandles && disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + if (!result.changed) { + return + } + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + }, + [persistDisabledLiveInputHandles] + ) + + const clearTerminalLiveInputDefault = useCallback( + (handle: string) => { + const liveHandles = new Set([ + ...liveInputTerminalHandlesRef.current, + ...defaultedLiveInputTerminalHandlesRef.current + ]) + liveHandles.delete(handle) + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false) + } + if (disabledLiveInputTerminalHandlesRef.current.delete(handle)) { + if (disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + } + pruneTerminalHandlesFromLiveInput(liveHandles) + }, + [persistDisabledLiveInputHandles, pruneTerminalHandlesFromLiveInput] + ) + + const toggleTerminalLiveInput = useCallback( + (handle: string): boolean => { + const nextEnabled = !liveInputTerminalHandlesRef.current.has(handle) + if (nextEnabled) { + disabledLiveInputTerminalHandlesRef.current.delete(handle) + } else { + disabledLiveInputTerminalHandlesRef.current.add(handle) + } + // Why: pre-hydration edits must patch the loaded set per handle; replacing + // the loaded set would erase other persisted opt-outs for this worktree. + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, !nextEnabled) + } + // Why: only persist after hydration; an earlier write would use the + // reset-empty ref and overwrite other handles' opt-outs for the worktree. + if (disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + setLiveInputTerminalHandles((prev) => { + const next = new Set(prev) + if (nextEnabled) { + next.add(handle) + } else { + next.delete(handle) + } + liveInputTerminalHandlesRef.current = next + return next + }) + return nextEnabled + }, + [persistDisabledLiveInputHandles] + ) + + useEffect(() => { + liveInputTerminalHandlesRef.current = new Set() + defaultedLiveInputTerminalHandlesRef.current = new Set() + disabledLiveInputTerminalHandlesRef.current = new Set() + disabledLiveInputHydratedRef.current = false + pendingDisabledLiveInputHydrationEditsRef.current = new Map() + pendingLiveInputDefaultHandlesRef.current = new Set() + setLiveInputTerminalHandles(new Set()) + + let disposed = false + // Why: load the persisted opt-outs first so defaulting logic (which can + // fire immediately from subscriptions) respects prior user choices. + void readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId).then((preference) => { + if (disposed) { + return + } + const pendingEdits = pendingDisabledLiveInputHydrationEditsRef.current + const hydratedDisabledHandles = new Set(preference.handles) + for (const [handle, disabled] of pendingEdits) { + if (disabled) { + hydratedDisabledHandles.add(handle) + } else { + hydratedDisabledHandles.delete(handle) + } + } + disabledLiveInputTerminalHandlesRef.current = hydratedDisabledHandles + disabledLiveInputHydratedRef.current = true + pendingDisabledLiveInputHydrationEditsRef.current = new Map() + if (preference.loaded && pendingEdits.size > 0) { + persistDisabledLiveInputHandles() + } + const result = applyDisabledTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + hydratedDisabledHandles + ) + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + const pendingDefaultHandles = [...pendingLiveInputDefaultHandlesRef.current] + pendingLiveInputDefaultHandlesRef.current.clear() + defaultTerminalHandlesToLiveInput(pendingDefaultHandles) + }) + return () => { + disposed = true + } + }, [defaultTerminalHandlesToLiveInput, hostId, persistDisabledLiveInputHandles, worktreeId]) + + return { + clearTerminalLiveInputDefault, + defaultTerminalHandlesToLiveInput, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + pruneTerminalHandlesFromLiveInput, + toggleTerminalLiveInput + } +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index c0f8596900a..1e5aee95de1 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -8,9 +8,12 @@ import { HOST_SIDEBAR_MIN_WIDTH, clampHostDockWidth, clampHostSidebarWidth, + loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, loadTerminalAutocompleteEnabled, loadTerminalLinkOpenMode, + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode @@ -63,6 +66,62 @@ describe('terminal autocomplete preference', () => { }) }) +describe('terminal live input disabled handles preference', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + it('defaults to no disabled handles when unset', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + await expect( + readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1') + ).resolves.toEqual({ handles: new Set(), loaded: true }) + }) + + it('loads only string terminal handles from storage', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['pty-1', 42, 'pty-2'])) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set(['pty-1', 'pty-2']) + ) + }) + + it('falls back to no disabled handles for invalid or unreadable storage', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json') + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + + vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable')) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + await expect( + readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1') + ).resolves.toEqual({ handles: new Set(), loaded: false }) + }) + + it('persists disabled handles per host and worktree', async () => { + await saveDisabledTerminalLiveInputHandles( + 'host/one', + 'folder:C:\\repo', + new Set(['pty-2', 'pty-1']) + ) + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:terminalLiveInputDisabled:host%2Fone:folder%3AC%3A%5Crepo', + JSON.stringify(['pty-2', 'pty-1']) + ) + }) +}) + describe('host sidebar width preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 5151397a7f0..d5b0db3a6e7 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -73,6 +73,53 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled)) } +const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:' + +export type DisabledTerminalLiveInputHandlesPreference = { + readonly handles: Set + readonly loaded: boolean +} + +function terminalLiveInputDisabledKey(hostId: string, worktreeId: string): string { + return `${TERMINAL_LIVE_INPUT_DISABLED_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent( + worktreeId + )}` +} + +export async function readDisabledTerminalLiveInputHandlesPreference( + hostId: string, + worktreeId: string +): Promise { + try { + const raw = await AsyncStorage.getItem(terminalLiveInputDisabledKey(hostId, worktreeId)) + if (!raw) { + return { handles: new Set(), loaded: true } + } + return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true } + } catch { + return { handles: new Set(), loaded: false } + } +} + +export async function loadDisabledTerminalLiveInputHandles( + hostId: string, + worktreeId: string +): Promise> { + const preference = await readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId) + return preference.handles +} + +export async function saveDisabledTerminalLiveInputHandles( + hostId: string, + worktreeId: string, + handles: ReadonlySet +): Promise { + await AsyncStorage.setItem( + terminalLiveInputDisabledKey(hostId, worktreeId), + JSON.stringify([...handles]) + ) +} + const SIDEBAR_WIDTH_KEY = 'orca:hostSidebarWidth' // Bounds for the draggable host worktree-list sidebar on tablet/foldable diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts index db3e96236bc..95b4960f928 100644 --- a/mobile/src/terminal/terminal-live-input.test.ts +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_LIVE_INPUT_MAX_BYTES, + applyDisabledTerminalLiveInputHandles, clearTerminalLiveInputFocusTimer, defaultTerminalLiveInputHandles, + filterTerminalLiveInputDefaultCandidates, getTerminalLiveSpecialKeyBytes, isTerminalLiveInputWithinByteLimit, pruneTerminalLiveInputHandles, @@ -101,6 +103,35 @@ describe('terminal live input', () => { expect(result.defaultedHandles).toBe(defaulted) }) + it('does not default persisted buffered-mode handles back to live input on reentry', () => { + const defaultableHandles = filterTerminalLiveInputDefaultCandidates( + ['pty-1', 'pty-2'], + new Set(['pty-1']) + ) + + const result = defaultTerminalLiveInputHandles( + new Set(), + new Set(['pty-1']), + defaultableHandles + ) + + expect(defaultableHandles).toEqual(['pty-2']) + expect([...result.enabledHandles]).toEqual(['pty-2']) + expect([...result.defaultedHandles]).toEqual(['pty-1', 'pty-2']) + }) + + it('reconciles persisted buffered-mode handles with currently enabled live input', () => { + const result = applyDisabledTerminalLiveInputHandles( + new Set(['pty-1', 'pty-2']), + new Set(['pty-2']), + new Set(['pty-1']) + ) + + expect(result.changed).toBe(true) + expect([...result.enabledHandles]).toEqual(['pty-2']) + expect([...result.defaultedHandles]).toEqual(['pty-2', 'pty-1']) + }) + it('prunes terminal handles that disappear from session snapshots', () => { const result = pruneTerminalLiveInputHandles( new Set(['pty-1', 'pty-stale']), diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts index dd5f8e6a709..96f279a4855 100644 --- a/mobile/src/terminal/terminal-live-input.ts +++ b/mobile/src/terminal/terminal-live-input.ts @@ -118,6 +118,48 @@ export function defaultTerminalLiveInputHandles( } } +export function filterTerminalLiveInputDefaultCandidates( + terminalHandles: readonly string[], + disabledHandles: ReadonlySet +): string[] { + return terminalHandles.filter((handle) => !disabledHandles.has(handle)) +} + +export function applyDisabledTerminalLiveInputHandles( + enabledHandles: ReadonlySet, + defaultedHandles: ReadonlySet, + disabledHandles: ReadonlySet +): TerminalLiveInputDefaultResult { + let nextEnabledHandles: Set | null = null + let nextDefaultedHandles: Set | null = null + + for (const handle of enabledHandles) { + if (!disabledHandles.has(handle)) { + continue + } + nextEnabledHandles ??= new Set(enabledHandles) + nextEnabledHandles.delete(handle) + } + + for (const handle of disabledHandles) { + if (defaultedHandles.has(handle)) { + continue + } + nextDefaultedHandles ??= new Set(defaultedHandles) + nextDefaultedHandles.add(handle) + } + + if (!nextEnabledHandles && !nextDefaultedHandles) { + return { enabledHandles, defaultedHandles, changed: false } + } + + return { + enabledHandles: nextEnabledHandles ?? enabledHandles, + defaultedHandles: nextDefaultedHandles ?? defaultedHandles, + changed: true + } +} + export function pruneTerminalLiveInputHandles( enabledHandles: ReadonlySet, defaultedHandles: ReadonlySet, From 09fc57e11387e982db1b8a549b399f4db580351d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 02:24:24 -0400 Subject: [PATCH 55/70] fix(browser): keep CDP reload from closing Orca browser tabs Fix the per-tab CDP proxy reload path so raw Page.reload keeps the Orca browser tab alive. Lifecycle priming is bounded and root reloads use the same safer webContents reload behavior as Orca's own browser reload path. --- src/main/browser/cdp-ws-proxy.test.ts | 184 +++++++++++++++++++++++++- src/main/browser/cdp-ws-proxy.ts | 117 ++++++++++++++-- 2 files changed, 287 insertions(+), 14 deletions(-) diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 9f345a2e6ee..11fbcd772cf 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -21,7 +21,9 @@ function createMockWebContents() { detach: vi.fn(() => { debuggerAttached = false }), - sendCommand: vi.fn(async () => ({})), + sendCommand: vi.fn( + async (_method?: string, _params?: Record, _sessionId?: string) => ({}) + ), on: vi.fn((event: string, handler: DebuggerListener) => { const arr = listeners.get(event) ?? [] arr.push(handler) @@ -41,6 +43,8 @@ function createMockWebContents() { debugger: debuggerObj, isDestroyed: () => destroyed, focus: vi.fn(), + reload: vi.fn(), + reloadIgnoringCache: vi.fn(), getTitle: vi.fn(() => 'Example'), getURL: vi.fn(() => 'https://example.com') }, @@ -89,6 +93,22 @@ describe('CdpWsProxy', () => { }) } + type SendCommandCall = [string, Record?, string?] + + function getSendCommandCalls(): SendCommandCall[] { + const calls = mock.webContents.debugger.sendCommand.mock.calls as unknown as [ + string, + Record?, + string? + ][] + return calls + } + + function getSendCommandMethods(): string[] { + const calls = getSendCommandCalls() + return calls.map((call) => call[0]) + } + it('starts on a random port and returns ws:// URL', () => { expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/) expect(proxy.getPort()).toBeGreaterThan(0) @@ -311,6 +331,168 @@ describe('CdpWsProxy', () => { client.close() }) + it('primes lifecycle events for Page.navigate', async () => { + const client = await connect() + + 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()).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() + + const response = await sendAndReceive(client, { + id: 12, + method: 'Page.reload' + }) + + expect(response.id).toBe(12) + expect(response.result).toEqual({}) + expect(getSendCommandMethods()).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'Network.enable', + 'Page.enable', + 'Page.setLifecycleEventsEnabled' + ]) + expect(mock.webContents.reload).toHaveBeenCalledTimes(1) + expect(getSendCommandMethods()).not.toContain('Page.reload') + client.close() + }) + + it('preserves explicit Page.navigate session during lifecycle priming', async () => { + const client = await connect() + + await sendAndReceive(client, { + id: 14, + method: 'Page.navigate', + params: { url: 'https://example.com/frame' }, + sessionId: 'iframe-session-123' + }) + + expect(getSendCommandCalls().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() + + await sendAndReceive(client, { + id: 15, + method: 'Page.reload', + params: { ignoreCache: true }, + sessionId: 'iframe-session-123' + }) + + expect(getSendCommandCalls().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() + + 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()).not.toContain('Network.enable') + client.close() + }) + + it('still reloads when lifecycle priming stalls', async () => { + const client = await connect() + 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() + 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() + + 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()).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'Runtime.evaluate' + ]) + client.close() + }) + // ── Page.frameNavigated interception ── // ── Cleanup ── diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 2531822b982..ec8de5be176 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -6,6 +6,8 @@ import { captureScreenshot } from './cdp-screenshot' 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 { private httpServer: Server | null = null private wss: WebSocketServer | null = null @@ -298,13 +300,39 @@ export class CdpWsProxy { } // Why: agent-browser waits for network idle to detect navigation completion. // Electron webview CDP subscriptions silently lapse after cross-process swaps. + // Page.reload needs the same priming: forwarding it unprimed closed the tab (#7031). if (msg.method === 'Page.navigate' && !this.webContents.isDestroyed()) { - void this.navigateWithLifecycleEnsured(client, clientId, msg.params ?? {}) + void this.navigateWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId) + return + } + // Why: CDP Page.reload can destroy Electron webview targets during process swaps. + // Use the same direct webContents reload path as Orca's own browser.reload. + if (msg.method === 'Page.reload' && !this.webContents.isDestroyed()) { + void this.reloadWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId) return } this.forwardCommand(client, clientId, msg.method, msg.params ?? {}, msg.sessionId) } + private resolveDebuggerSessionId(msgSessionId?: string): string | undefined { + return msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined + } + + private isActiveClient(client: WebSocket): boolean { + return this.client === client && client.readyState === WebSocket.OPEN + } + + private sendDebuggerCommand( + method: string, + params: Record, + sessionId?: string + ): Promise { + const command = sessionId + ? this.webContents.debugger.sendCommand(method, params, sessionId) + : this.webContents.debugger.sendCommand(method, params) + return Promise.resolve(command) + } + private forwardCommand( client: WebSocket, clientId: number, @@ -316,10 +344,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 +358,85 @@ export class CdpWsProxy { } } - private async navigateWithLifecycleEnsured( + private async navigateWithLifecycle( client: WebSocket, clientId: number, - params: Record + params: Record, + msgSessionId?: string ): Promise { + await this.primePageLifecycle(this.resolveDebuggerSessionId(msgSessionId)) + if (!this.isActiveClient(client)) { + return + } + this.forwardCommand(client, clientId, 'Page.navigate', params, msgSessionId) + } + + private async reloadWithLifecycle( + client: WebSocket, + clientId: number, + params: Record, + msgSessionId?: string + ): Promise { + const sessionId = this.resolveDebuggerSessionId(msgSessionId) + const unsupportedParam = sessionId ? null : this.getUnsupportedRootReloadParam(params) + if (unsupportedParam) { + this.sendError( + clientId, + `Page.reload parameter "${unsupportedParam}" is not supported for Orca tab reloads`, + client + ) + return + } + await this.primePageLifecycle(sessionId) + if (!this.isActiveClient(client)) { + return + } + if (sessionId) { + this.forwardCommand(client, clientId, 'Page.reload', params, msgSessionId) + return + } + if (this.webContents.isDestroyed()) { + this.sendError(clientId, 'Browser tab is no longer available', client) + return + } try { - const dbg = this.webContents.debugger + if (params.ignoreCache === true) { + this.webContents.reloadIgnoringCache() + } else { + this.webContents.reload() + } + this.sendResult(clientId, {}, client) + } catch (err) { + this.sendError(clientId, err instanceof Error ? err.message : String(err), client) + } + } + + private getUnsupportedRootReloadParam(params: Record): string | null { + return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null + } + + private async primePageLifecycle(sessionId?: string): Promise { + let timeout: ReturnType | null = null + const priming = (async (): Promise => { // Why: without Network.enable, agent-browser never sees network idle → goto times out. - await dbg.sendCommand('Network.enable', {}) - await dbg.sendCommand('Page.enable', {}) - await dbg.sendCommand('Page.setLifecycleEventsEnabled', { enabled: true }) - } catch { - /* best-effort */ + await this.sendDebuggerCommand('Network.enable', {}, sessionId) + await this.sendDebuggerCommand('Page.enable', {}, sessionId) + await this.sendDebuggerCommand('Page.setLifecycleEventsEnabled', { enabled: true }, sessionId) + })().catch(() => {}) + + try { + await Promise.race([ + priming, + new Promise((resolve) => { + timeout = setTimeout(resolve, LIFECYCLE_PRIMING_TIMEOUT_MS) + timeout.unref?.() + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } } - this.forwardCommand(client, clientId, 'Page.navigate', params) } private handleScreenshot( From 2848c5daf47a3b76181179e42817f94582cded70 Mon Sep 17 00:00:00 2001 From: Mark Xian Date: Fri, 3 Jul 2026 14:37:12 +0800 Subject: [PATCH 56/70] Fix rename inputs committing on CJK IME Enter confirmation (#6980) Fixes #6980. --- .../settings/WorkspaceDirectorySetting.tsx | 10 ++-- ...eeTitleInlineRename.begin-editing.test.tsx | 50 ++++++++++++++++ .../sidebar/WorktreeTitleInlineRename.tsx | 6 ++ .../components/tab-bar/EditorFileTab.test.tsx | 57 ++++++++++++++++++- .../src/components/tab-bar/EditorFileTab.tsx | 6 ++ .../SortableTab.rename-shortcut.test.tsx | 49 +++++++++++++++- .../src/components/tab-bar/SortableTab.tsx | 6 ++ .../TerminalPaneHeaderOverlay.test.tsx | 53 +++++++++++++++-- .../TerminalPaneHeaderOverlay.tsx | 6 ++ .../ime-composition-keyboard-event.test.ts | 26 +++++++++ .../src/lib/ime-composition-keyboard-event.ts | 13 +++++ 11 files changed, 268 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/lib/ime-composition-keyboard-event.test.ts create mode 100644 src/renderer/src/lib/ime-composition-keyboard-event.ts diff --git a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx index 70fa7e3eb7f..8633b735a96 100644 --- a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx +++ b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx @@ -14,6 +14,7 @@ import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' import { useSidebarHostScopeOptions } from '../sidebar/use-sidebar-host-scope-options' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { buildHostScopeChoices, CLIENT_DEFAULT_SCOPE, @@ -197,7 +198,9 @@ export function WorkspaceDirectorySetting({ }} onBlur={handleBlur} onKeyDown={(e) => { - if (isComposingKeyboardEvent(e)) { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(e)) { return } if (e.key === 'Enter') { @@ -270,8 +273,3 @@ export function WorkspaceDirectorySetting({ ) } - -function isComposingKeyboardEvent(event: React.KeyboardEvent): boolean { - const nativeEvent = event.nativeEvent - return nativeEvent.isComposing || nativeEvent.keyCode === 229 -} diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx index 7b017362b1d..8958dd58543 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx @@ -68,6 +68,7 @@ async function renderTitleRename(props: { beginEditing: boolean disabled?: boolean onBeginEditingConsumed: () => void + onRename?: (displayName: string) => Promise | void }): Promise { 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 + stopPropagation: ReturnType +} { + 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') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx index 914c3eb2f9a..d7a98fe2981 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx @@ -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) => { 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() diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 0d48f3dfe41..59a1c18bcee 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -19,6 +19,8 @@ const appStoreMocks = vi.hoisted(() => ({ })) })) +const renameFileOnDiskMock = vi.hoisted(() => vi.fn()) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { @@ -147,7 +149,7 @@ vi.mock('@/components/editor/editor-labels', () => ({ })) vi.mock('@/lib/rename-file', () => ({ - renameFileOnDisk: vi.fn() + renameFileOnDisk: renameFileOnDiskMock })) vi.mock('@/lib/file-type-icons', () => ({ @@ -326,6 +328,27 @@ function findSpanByText(node: unknown, label: string): ReactElementLike { return span } +function pressInputKey( + input: ReactElementLike, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): { + preventDefault: ReturnType + stopPropagation: ReturnType +} { + 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('EditorFileTab rename menu', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -371,6 +394,38 @@ describe('EditorFileTab rename menu', () => { expect(select).not.toHaveBeenCalled() }) + it('ignores IME composition Enter before renaming the editor file tab', async () => { + const file = baseFile() + const firstRender = expandNode((await renderEditorFileTab(file)).element) + const renameItem = findMenuItemByText(firstRender, 'Rename') + + ;(renameItem.props.onSelect as () => void)() + + const secondRender = expandNode((await renderEditorFileTab(file)).element) + const input = findElementsByType(secondRender, 'input')[0] + const setInputRef = input.props.ref as (input: HTMLInputElement | null) => void + setInputRef({ + focus: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + value: '日本語.md' + } as unknown as HTMLInputElement) + + const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) + + expect(composingEvent.preventDefault).not.toHaveBeenCalled() + expect(renameFileOnDiskMock).not.toHaveBeenCalled() + + pressInputKey(input, 'Enter') + + expect(renameFileOnDiskMock).toHaveBeenCalledWith({ + oldPath: '/repo/untitled-5.md', + newName: '日本語.md', + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + }) + it('disables Rename for diff tabs that do not map to one writable file', async () => { const file = baseFile({ mode: 'diff', diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 694d03b0b59..3fb1a28ca5d 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { basename, normalizeRelativePath } from '@/lib/path' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import { renameFileOnDisk } from '@/lib/rename-file' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { detectLanguage } from '@/lib/language-detect' import { getFileTypeIcon } from '@/lib/file-type-icons' import { useRepoById, useWorktreeById } from '@/store/selectors' @@ -281,6 +282,11 @@ export default function EditorFileTab({ onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} onKeyDown={(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') { e.preventDefault() e.stopPropagation() diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index 10a81ab059c..bb4e757d1c8 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -208,7 +208,11 @@ function makeTerminalTab() { } } -async function renderSortableTab(): Promise { +async function renderSortableTab({ + onSetCustomTitle = vi.fn() +}: { + onSetCustomTitle?: (tabId: string, title: string | null) => void +} = {}): Promise { reactHookRuntime.index = 0 const module = await import('./SortableTab') return module.default({ @@ -224,7 +228,7 @@ async function renderSortableTab(): Promise { onClose: vi.fn(), onCloseOthers: vi.fn(), onCloseToRight: vi.fn(), - onSetCustomTitle: vi.fn(), + onSetCustomTitle, onSetTabColor: vi.fn(), onTogglePin: vi.fn(), onToggleExpand: vi.fn(), @@ -282,6 +286,25 @@ function findElementsByType(node: unknown, typeName: string): ReactElementLike[] return results } +function pressInputKey( + input: ReactElementLike, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): { + preventDefault: ReturnType +} { + const event = { + key, + nativeEvent: { + isComposing: options?.isComposing ?? false, + keyCode: options?.keyCode ?? 13 + }, + preventDefault: vi.fn() + } + ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) + return event +} + describe('SortableTab rename shortcut signal', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -312,4 +335,26 @@ describe('SortableTab rename shortcut signal', () => { expect(inputs[0].props.value).toBe('Runtime terminal title') expect(inputs[0].props['data-tab-rename-input']).toBe('true') }) + + it('ignores IME composition Enter before committing the custom tab title', async () => { + const onSetCustomTitle = vi.fn() + + await renderSortableTab({ onSetCustomTitle }) + let rerender = expandNode(await renderSortableTab({ onSetCustomTitle })) + let input = findElementsByType(rerender, 'input')[0] + ;(input.props.onChange as (event: { target: { value: string } }) => void)({ + target: { value: '日本語 terminal' } + }) + rerender = expandNode(await renderSortableTab({ onSetCustomTitle })) + input = findElementsByType(rerender, 'input')[0] + + const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) + + expect(composingEvent.preventDefault).not.toHaveBeenCalled() + expect(onSetCustomTitle).not.toHaveBeenCalled() + + pressInputKey(input, 'Enter') + + expect(onSetCustomTitle).toHaveBeenCalledWith('terminal-tab-1', '日本語 terminal') + }) }) diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index e19f1258059..3bef8529a75 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -5,6 +5,7 @@ import { ShellIcon } from './shell-icons' import { AgentIcon } from '@/lib/agent-catalog' import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration' import { useTabAgent } from '@/lib/use-tab-agent' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' @@ -333,6 +334,11 @@ export default function SortableTab({ onChange={(event) => setRenameValue(event.target.value)} onBlur={commitRename} onKeyDown={(event) => { + // 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() commitRename() diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx index 78e80b3231e..80a386b3c8d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx @@ -45,17 +45,24 @@ function renderOverlay({ paneCount = 2, showAlwaysOnHeaders = true, onClosePane = vi.fn(), - onRemoveTitle = vi.fn() + onRemoveTitle = vi.fn(), + onRenameSubmit = vi.fn(), + renameValue = '', + renamingPaneId = null }: { paneTitles: Record paneCount?: number showAlwaysOnHeaders?: boolean onClosePane?: ReturnType onRemoveTitle?: ReturnType + onRenameSubmit?: ReturnType + renameValue?: string + renamingPaneId?: number | null }): { container: HTMLDivElement onClosePane: ReturnType onRemoveTitle: ReturnType + onRenameSubmit: ReturnType } { const panes = [makePane(1), makePane(2)] const container = document.createElement('div') @@ -76,8 +83,8 @@ function renderOverlay({ 1: { left: 0, top: 0, width: 200 }, 2: { left: 220, top: 0, width: 200 } }} - renamingPaneId={null} - renameValue="" + renamingPaneId={renamingPaneId} + renameValue={renameValue} renameInputRef={createRef()} titleUsesLightSurface={false} paneTitleBackground="transparent" @@ -93,14 +100,31 @@ function renderOverlay({ onRemoveTitle={onRemoveTitle as (paneId: number) => void} onClosePane={onClosePane as (paneId: number) => void} onRenameValueChange={vi.fn()} - onRenameSubmit={vi.fn()} + onRenameSubmit={onRenameSubmit as () => void} onRenameCancel={vi.fn()} onRenameBlur={vi.fn()} /> ) }) mounted.push({ container, root }) - return { container, onClosePane, onRemoveTitle } + return { container, onClosePane, onRemoveTitle, onRenameSubmit } +} + +function pressInputKey( + input: HTMLInputElement, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): void { + act(() => { + const event = new KeyboardEvent('keydown', { key, bubbles: true }) + if (options?.isComposing !== undefined) { + Object.defineProperty(event, 'isComposing', { value: options.isComposing }) + } + if (options?.keyCode !== undefined) { + Object.defineProperty(event, 'keyCode', { value: options.keyCode }) + } + input.dispatchEvent(event) + }) } afterEach(() => { @@ -142,4 +166,23 @@ describe('TerminalPaneHeaderOverlay', () => { expect(onClosePane).toHaveBeenCalledWith(1) expect(onRemoveTitle).not.toHaveBeenCalled() }) + + it('ignores IME composition Enter before submitting a pane title rename', () => { + const { container, onRenameSubmit } = renderOverlay({ + paneTitles: { 1: 'server', 2: '' }, + renamingPaneId: 1, + renameValue: '日本語 pane' + }) + const input = container.querySelector('.pane-title-input') + + expect(input).not.toBeNull() + + pressInputKey(input as HTMLInputElement, 'Enter', { isComposing: true }) + + expect(onRenameSubmit).not.toHaveBeenCalled() + + pressInputKey(input as HTMLInputElement, 'Enter') + + expect(onRenameSubmit).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index 3ad25d41bdd..fc1602f866e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import type { PtyTransport } from './pty-transport' import { handleInternalTerminalFileDrop } from './terminal-drop-handler' @@ -183,6 +184,11 @@ export default function TerminalPaneHeaderOverlay({ value={renameValue} onChange={(event) => onRenameValueChange(event.target.value)} onKeyDown={(event) => { + // 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') { onRenameSubmit() } else if (event.key === 'Escape') { diff --git a/src/renderer/src/lib/ime-composition-keyboard-event.test.ts b/src/renderer/src/lib/ime-composition-keyboard-event.test.ts new file mode 100644 index 00000000000..daa02c5411e --- /dev/null +++ b/src/renderer/src/lib/ime-composition-keyboard-event.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import { isImeCompositionKeyDown } from './ime-composition-keyboard-event' + +function keyEvent(nativeEvent: { isComposing?: boolean; keyCode?: number }): ReactKeyboardEvent { + return { + nativeEvent: { + isComposing: nativeEvent.isComposing ?? false, + keyCode: nativeEvent.keyCode ?? 13 + } + } as unknown as ReactKeyboardEvent +} + +describe('isImeCompositionKeyDown', () => { + it('is true while the IME is composing', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: true }))).toBe(true) + }) + + it('is true for the keyCode 229 fallback when isComposing is not set', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 229 }))).toBe(true) + }) + + it('is false for a plain Enter outside of composition', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 13 }))).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/ime-composition-keyboard-event.ts b/src/renderer/src/lib/ime-composition-keyboard-event.ts new file mode 100644 index 00000000000..c5faed78d16 --- /dev/null +++ b/src/renderer/src/lib/ime-composition-keyboard-event.ts @@ -0,0 +1,13 @@ +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' + +/** + * Why: CJK IMEs (Japanese/Chinese/Korean) fire a keydown for the Enter that + * only confirms a conversion candidate. Rename/title inputs that commit on + * `Enter` must ignore that keydown, otherwise they submit mid-composition with a + * half-converted value. `isComposing` covers most browsers; `keyCode === 229` is + * a defensive fallback for IMEs that don't set `isComposing` on keydown. + */ +export function isImeCompositionKeyDown(event: ReactKeyboardEvent): boolean { + const nativeEvent = event.nativeEvent + return nativeEvent.isComposing || nativeEvent.keyCode === 229 +} From 10e8f8689975fe7fa6faef3012bf276cc3e0a382 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:43:35 -0700 Subject: [PATCH 57/70] Coalesce PTY input write bursts (#7205) Co-authored-by: Orca --- .../pty-input-write-queue.test.ts | 157 ++++++++++++++ .../terminal-pane/pty-input-write-queue.ts | 155 ++++++++++++++ .../components/terminal-pane/pty-transport.ts | 101 +-------- .../pane-terminal-mouse-wheel.test.ts | 150 +++++++++++++- .../pane-manager/pane-terminal-mouse-wheel.ts | 4 +- .../pane-terminal-tui-wheel-reports.ts | 54 ++++- .../fixtures/visible-tui-scroll-fixture.cjs | 43 +++- tests/e2e/terminal-tui-wheel-drain.spec.ts | 195 ++++++++++++++++++ 8 files changed, 756 insertions(+), 103 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-input-write-queue.ts create mode 100644 tests/e2e/terminal-tui-wheel-drain.spec.ts diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts new file mode 100644 index 00000000000..ddf856f05ae --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS, + createPtyInputWriteQueue +} from './pty-input-write-queue' +import { + TERMINAL_INPUT_CHUNK_MAX_BYTES, + TERMINAL_INPUT_MAX_BYTES +} from '../../../../shared/terminal-input' + +const WHEEL_UP_REPORT = '\x1b[<64;60;20M' + +type WriteRecord = { id: string; data: string } + +function createRecordingQueue(options: { writable?: () => boolean } = {}): { + writes: WriteRecord[] + queue: ReturnType +} { + const writes: WriteRecord[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => options.writable?.() ?? true, + write: (id, data) => writes.push({ id, data }) + }) + return { writes, queue } +} + +describe('pty input write queue', () => { + it('coalesces a dense burst of wheel reports instead of one write per macrotask turn', async () => { + const { writes, queue } = createRecordingQueue() + + // Simulates a 2s aggressive trackpad gesture at 120Hz: 240 SGR reports + // enqueued while the drain cannot run between events. + for (let i = 0; i < 240; i += 1) { + expect(queue.enqueue('pty-1', WHEEL_UP_REPORT)).toBe(true) + } + await queue.waitForDrain() + + // First report flushes immediately (keystroke latency); everything queued + // behind it must drain as a single coalesced write, not 239 timer turns. + expect(writes.length).toBe(2) + expect(writes[0]?.data).toBe(WHEEL_UP_REPORT) + expect(writes[1]?.data).toBe(WHEEL_UP_REPORT.repeat(239)) + expect(writes.map((write) => write.id)).toEqual(['pty-1', 'pty-1']) + }) + + it('preserves byte order and content across coalesced writes', async () => { + const { writes, queue } = createRecordingQueue() + + const inputs = ['a', '\x1b[<65;1;1M', 'bc', '\x1b[A', 'd'] + for (const input of inputs) { + queue.enqueue('pty-1', input) + } + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(inputs.join('')) + }) + + it('does not coalesce across different PTY ids', async () => { + const writes: WriteRecord[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (id, data) => writes.push({ id, data }) + }) + + queue.enqueue('pty-1', 'a') + queue.enqueue('pty-1', 'b') + queue.enqueue('pty-2', 'c') + queue.enqueue('pty-1', 'd') + await queue.waitForDrain() + + expect(writes).toEqual([ + { id: 'pty-1', data: 'a' }, + { id: 'pty-1', data: 'b' }, + { id: 'pty-2', data: 'c' }, + { id: 'pty-1', data: 'd' } + ]) + }) + + it('keeps coalesced payloads under the input chunk byte cap', async () => { + const { writes, queue } = createRecordingQueue() + + const piece = 'x'.repeat(1000) + for (let i = 0; i < 12; i += 1) { + queue.enqueue('pty-1', piece) + } + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(piece.repeat(12)) + for (const write of writes) { + expect(write.data.length).toBeLessThanOrEqual(TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS) + } + expect(writes.length).toBeGreaterThan(1) + }) + + it('still chunks oversized items and keeps trailing input ordered behind them', async () => { + const { writes, queue } = createRecordingQueue() + + const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 100) + queue.enqueue('pty-1', 'before') + queue.enqueue('pty-1', large) + queue.enqueue('pty-1', 'after') + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(`before${large}after`) + expect(writes.at(-1)?.data).toBe('after') + for (const write of writes) { + expect(write.data.length).toBeLessThanOrEqual(TERMINAL_INPUT_CHUNK_MAX_BYTES) + } + }) + + it('rejects input over the terminal input byte limit without writing', async () => { + const { writes, queue } = createRecordingQueue() + + expect(queue.enqueue('pty-1', 'z'.repeat(TERMINAL_INPUT_MAX_BYTES + 1))).toBe(false) + await queue.waitForDrain() + + expect(writes).toEqual([]) + }) + + it('drops queued input for PTYs that are no longer writable', async () => { + let writable = true + const { writes, queue } = createRecordingQueue({ writable: () => writable }) + + queue.enqueue('pty-1', 'a') + writable = false + queue.enqueue('pty-1', 'b') + await queue.waitForDrain() + + expect(writes).toEqual([{ id: 'pty-1', data: 'a' }]) + }) + + it('clear() drops pending input that has not been written yet', async () => { + const writes: WriteRecord[] = [] + const pendingYields: (() => void)[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (id, data) => writes.push({ id, data }), + yieldBetweenWrites: () => + new Promise((resolve) => { + pendingYields.push(resolve) + }) + }) + + const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 3) + queue.enqueue('pty-1', large) + queue.enqueue('pty-1', 'tail') + // First chunk is written synchronously, then the drain parks on the yield. + expect(writes.length).toBe(1) + + queue.clear() + pendingYields.shift()?.() + await queue.waitForDrain() + + expect(writes.length).toBe(1) + expect(writes.map((write) => write.data).join('')).not.toContain('tail') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts new file mode 100644 index 00000000000..b20e764df15 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -0,0 +1,155 @@ +import { + isTerminalInputTooLargeWithDeferredMeasurement, + iterateTerminalInputChunks +} from '../../../../shared/terminal-input' + +// Why: 4096 UTF-16 code units encode to at most ~12KB UTF-8, safely under the +// 16KB TERMINAL_INPUT_CHUNK_MAX_BYTES cap without paying byte measurement on +// the hot input path. +export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096 + +type PendingPtyInputWrite = { + id: string + text: string + tooLarge: boolean | Promise + chunks?: Iterator + nextChunk?: string +} + +export type PtyInputWriteQueue = { + enqueue: (id: string, data: string) => boolean + waitForDrain: () => Promise + clear: () => void +} + +export type PtyInputWriteQueueDeps = { + isWritable: (id: string) => boolean + write: (id: string, data: string) => void + yieldBetweenWrites?: () => Promise +} + +function defaultYieldBetweenWrites(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +function isCoalescibleText(text: string): boolean { + return text.length <= TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS +} + +export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInputWriteQueue { + const yieldBetweenWrites = deps.yieldBetweenWrites ?? defaultYieldBetweenWrites + let pending: PendingPtyInputWrite[] = [] + let drainPromise: Promise | null = null + + async function drain(): Promise { + while (pending.length > 0) { + const next = pending[0] + if (!next) { + pending.shift() + continue + } + if (!deps.isWritable(next.id)) { + pending.shift() + continue + } + if (next.tooLarge !== false) { + next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true) + if (next.tooLarge) { + pending.shift() + continue + } + if (!deps.isWritable(next.id)) { + pending.shift() + continue + } + } + // Why: dense input streams (SGR wheel reports during trackpad momentum, + // key auto-repeat) enqueue one tiny item per event. Writing one item per + // macrotask turn lets Chromium's nested-timer clamp pace the drain at + // ≥4ms per item, so a fast gesture's reports reach the PTY seconds after + // the gesture ended and the TUI visibly replays them one by one. + // Coalescing consecutive validated small items into a single write keeps + // the PTY byte stream identical while draining the backlog in one turn. + if (next.chunks === undefined && isCoalescibleText(next.text)) { + let payload = next.text + pending.shift() + while (pending.length > 0) { + const peek = pending[0] + if ( + !peek || + peek.id !== next.id || + peek.tooLarge !== false || + peek.chunks !== undefined || + !isCoalescibleText(peek.text) || + payload.length + peek.text.length > TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS + ) { + break + } + payload += peek.text + pending.shift() + } + deps.write(next.id, payload) + if (pending.length > 0) { + await yieldBetweenWrites() + } + continue + } + next.chunks ??= iterateTerminalInputChunks(next.text) + const chunk = + next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk } + next.nextChunk = undefined + if (chunk.done) { + pending.shift() + continue + } + deps.write(next.id, chunk.value) + const following = next.chunks.next() + if (following.done) { + pending.shift() + } else { + next.nextChunk = following.value + } + if (pending.length > 0) { + await yieldBetweenWrites() + } + } + } + + function scheduleDrain(): void { + if (drainPromise) { + return + } + drainPromise = drain().finally(() => { + drainPromise = null + if (pending.length > 0) { + scheduleDrain() + } + }) + } + + return { + enqueue(id: string, data: string): boolean { + try { + const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) + if (tooLarge === true) { + return false + } + pending.push({ id, text: data, tooLarge }) + scheduleDrain() + return true + } catch { + return false + } + }, + + async waitForDrain(): Promise { + while (drainPromise) { + await drainPromise + } + }, + + clear(): void { + pending = [] + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 474ee267832..f15f6ea990b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -23,6 +23,7 @@ import { getEagerPtyBufferHandle } from './pty-dispatcher' import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer' +import { createPtyInputWriteQueue } from './pty-input-write-queue' import type { PtyDataMeta } from './pty-dispatcher' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' import { createBellDetector } from './bell-detector' @@ -59,14 +60,6 @@ const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared const MAX_PTY_SIDE_EFFECTS_PER_DRAIN = 64 -type PendingPtyInputWrite = { - id: string - text: string - tooLarge: boolean | Promise - chunks?: Iterator - nextChunk?: string -} - // Why: onAgentStatus callback added to IpcPtyTransportOptions in pty-dispatcher // so the OSC 9999 status payloads can be forwarded to the store. @@ -472,8 +465,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // unread marks, or notifications for unrelated worktrees just because Orca // is reconnecting background terminals on launch. let suppressAttentionEvents = false - let pendingInputWrites: PendingPtyInputWrite[] = [] - let inputWriteDrainPromise: Promise | null = null + const inputWriteQueue = createPtyInputWriteQueue({ + isWritable: (id) => connected && ptyId === id, + write: (id, data) => window.api.pty.write(id, data) + }) const outputProcessor = createPtyOutputProcessor({ onTitleChange, onBell, @@ -541,84 +536,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return new Promise((resolve) => setTimeout(resolve, 0)) } - async function drainPendingInputWrites(): Promise { - while (pendingInputWrites.length > 0) { - const next = pendingInputWrites[0] - if (!next) { - continue - } - if (!connected || ptyId !== next.id) { - pendingInputWrites.shift() - continue - } - if (next.tooLarge !== false) { - next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true) - if (next.tooLarge) { - pendingInputWrites.shift() - continue - } - if (!connected || ptyId !== next.id) { - pendingInputWrites.shift() - continue - } - } - next.chunks ??= iterateTerminalInputChunks(next.text) - const chunk = - next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk } - next.nextChunk = undefined - if (chunk.done) { - pendingInputWrites.shift() - continue - } - window.api.pty.write(next.id, chunk.value) - const following = next.chunks.next() - if (following.done) { - pendingInputWrites.shift() - } else { - next.nextChunk = following.value - } - if (pendingInputWrites.length > 0) { - await yieldToInputWriteDrain() - } - } - } - - function schedulePendingInputWriteDrain(): void { - if (inputWriteDrainPromise) { - return - } - inputWriteDrainPromise = drainPendingInputWrites().finally(() => { - inputWriteDrainPromise = null - if (pendingInputWrites.length > 0) { - schedulePendingInputWriteDrain() - } - }) - } - - function clearPendingInputWrites(): void { - pendingInputWrites = [] - } - - function enqueuePtyInputWrite(id: string, data: string): boolean { - try { - const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) - if (tooLarge === true) { - return false - } - pendingInputWrites.push({ id, text: data, tooLarge }) - schedulePendingInputWriteDrain() - return true - } catch { - return false - } - } - - async function waitForPendingInputWrites(): Promise { - while (inputWriteDrainPromise) { - await inputWriteDrainPromise - } - } - async function writeAcceptedPtyInput(id: string, data: string): Promise { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) @@ -887,7 +804,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra disconnect() { clearAccumulatedState() - clearPendingInputWrites() + inputWriteQueue.clear() if (ptyId) { const id = ptyId window.api.pty.kill(id) @@ -900,7 +817,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra detach() { clearAccumulatedState() - clearPendingInputWrites() + inputWriteQueue.clear() if (ptyId) { // Why: detach() is used for in-session remounts such as moving a tab // between split groups. Stop delivering data/title events into the @@ -918,7 +835,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra if (!connected || !ptyId) { return false } - return enqueuePtyInputWrite(ptyId, data) + return inputWriteQueue.enqueue(ptyId, data) }, ...(connectionId @@ -929,7 +846,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return false } const id = ptyId - await waitForPendingInputWrites() + await inputWriteQueue.waitForDrain() if (!connected || ptyId !== id) { return false } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts index 3a15574255f..05190661d5d 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts @@ -243,6 +243,66 @@ describe('terminal mouse wheel multiplier', () => { expect(reports).toEqual([0, 0, 0, 1]) }) + it('does not burst-boost rapid trackpad-like pixel deltas', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 16, 32, 48].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 4, deltaMode: DOM_DELTA_PIXEL, timeStamp }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([0, 0, 0, 1]) + }) + + it('tracks a decaying trackpad-like momentum tail with linear row distance', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16, 20, 24, 28, 20, 12, 6, 3].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 2, 1, 1, 0, 1]) + }) + + it('drops pending trackpad distance on each direction change', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16, 20, 24, -16, -20, -24, -18, -10, 6, -4].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 0, 0]) + }) + + it('emits the full linear distance for a fast trackpad-like flick event', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16 * 12, 16 * 12, 16 * 12].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([12, 12, 12]) + }) + it('resets pending fractional distance when the user changes direction', () => { const state = createTerminalTuiMouseWheelDistanceState() @@ -269,7 +329,7 @@ describe('terminal mouse wheel multiplier', () => { expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement(false))).toBe(false) }) - it('leaves trackpad-like pixel scrolling one-to-one', () => { + it('handles trackpad-like TUI pixel scrolling while mouse reporting is active', () => { expect( shouldMultiplyTerminalMouseWheel( wheelEvent({ @@ -278,7 +338,7 @@ describe('terminal mouse wheel multiplier', () => { }), terminalElement() ) - ).toBe(false) + ).toBe(true) }) it('multiplies notched mouse wheel ticks even when Chromium exposes a small pixel delta', () => { @@ -358,6 +418,92 @@ describe('terminal mouse wheel multiplier', () => { expect(shouldMultiplyTerminalMouseWheel(dispatched[0]!, target)).toBe(false) }) + it('replays trackpad-like TUI pixel scrolling with responsive direction reversal', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target, + rows: 24 + }, + { getTuiMouseWheelMultiplier: () => 1 } + ) + + const events = [ + [4, 0], + [4, 16], + [4, 32], + [4, 48], + [-4, 64], + [-4, 80], + [-4, 96], + [-4, 112] + ].map(([deltaY, timeStamp]) => { + const event = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY + }) as WheelEvent + Object.defineProperty(event, 'timeStamp', { + configurable: true, + value: timeStamp + }) + return event + }) + + for (const event of events) { + expect(handlers[0]?.(event)).toBe(false) + await Promise.resolve() + } + + expect(dispatched.map((entry) => entry.deltaY)).toEqual([1, -1]) + }) + + it('replays a fast trackpad-like flick as one full synthetic report batch', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target, + rows: 24 + }, + { getTuiMouseWheelMultiplier: () => 1 } + ) + + const event = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 16 * 12 + }) as WheelEvent + + expect(handlers[0]?.(event)).toBe(false) + await Promise.resolve() + + expect(dispatched.map((entry) => entry.deltaY)).toEqual(Array(12).fill(1)) + }) + it('drains resolved TUI wheel reports without a frame-rate cap', async () => { vi.stubGlobal('WheelEvent', TestWheelEvent) const handlers: ((event: WheelEvent) => boolean)[] = [] diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts index ab192e393f7..f35c31efba7 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts @@ -1,7 +1,6 @@ import type { Terminal } from '@xterm/xterm' import { createTerminalTuiMouseWheelDistanceState, - isDiscreteTerminalTuiWheelEvent, normalizeTerminalTuiMouseWheelMultiplier, resolveTerminalTuiMouseWheelReportCount, resolveTerminalWheelDirection @@ -110,8 +109,7 @@ export function shouldMultiplyTerminalMouseWheel( isReplayedWheelEvent(event) || !terminalElement?.classList.contains(XTERM_MOUSE_REPORTING_CLASS) || event.deltaY === 0 || - event.shiftKey || - !isDiscreteTerminalTuiWheelEvent(event) + event.shiftKey ) { return false } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts b/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts index 1b92edb390f..8736dc6504e 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts @@ -67,6 +67,11 @@ function legacyVerticalWheelDelta(event: TerminalTuiWheelEventInput): number | n return null } +function hasDiscreteLegacyWheelDelta(event: TerminalTuiWheelEventInput): boolean { + const legacyDelta = legacyVerticalWheelDelta(event) + return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN +} + export function isDiscreteTerminalTuiWheelEvent(event: TerminalTuiWheelEventInput): boolean { if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) { return true @@ -76,8 +81,21 @@ export function isDiscreteTerminalTuiWheelEvent(event: TerminalTuiWheelEventInpu return true } - const legacyDelta = legacyVerticalWheelDelta(event) - return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN + return hasDiscreteLegacyWheelDelta(event) +} + +function canBurstBoostWheelEvent(event: TerminalTuiWheelEventInput): boolean { + if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) { + return true + } + + return hasDiscreteLegacyWheelDelta(event) +} + +function isTrackpadLikePixelWheelEvent(event: TerminalTuiWheelEventInput): boolean { + return ( + (event.deltaMode ?? DOM_DELTA_PIXEL) === DOM_DELTA_PIXEL && !hasDiscreteLegacyWheelDelta(event) + ) } function wheelInputTime(event: TerminalTuiWheelEventInput): number | null { @@ -130,6 +148,13 @@ function resolveBurstWheelDistanceRows( state: TerminalTuiMouseWheelDistanceState, distanceRows: number ): number { + if (!canBurstBoostWheelEvent(event)) { + state.fastStreak = 0 + state.lastDistanceRows = null + state.lastInputAt = null + return 0 + } + const currentInputAt = wheelInputTime(event) if (currentInputAt === null) { state.fastStreak = 0 @@ -165,6 +190,26 @@ function resolveBurstWheelDistanceRows( return TUI_WHEEL_BURST_MAX_BONUS_ROWS * cadence * (state.fastStreak / TUI_WHEEL_BURST_RAMP_EVENTS) } +function resolveTrackpadPixelWheelReportCount( + event: TerminalTuiWheelEventInput, + state: TerminalTuiMouseWheelDistanceState, + distanceRows: number +): number | null { + if (!isTrackpadLikePixelWheelEvent(event)) { + return null + } + + // Why: trackpad pixel streams map 1:1 to physical distance: one report per + // terminal row scrolled, fractional remainder carried. No per-event cap and + // no momentum-tail suppression: the input write queue batches whatever a + // busy frame accumulates into a single PTY write, so the TUI applies it at + // once instead of replaying it, and inertial scrolling stays real-time. + const totalRows = state.pendingRows + distanceRows + const reports = Math.trunc(totalRows) + state.pendingRows = totalRows - reports + return reports +} + export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number { if (typeof value !== 'number' || !Number.isFinite(value)) { return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER @@ -193,6 +238,11 @@ export function resolveTerminalTuiMouseWheelReportCount( state.pendingDirection = direction const distanceRows = resolveWheelDistanceRows(event, metrics) + const trackpadReportCount = resolveTrackpadPixelWheelReportCount(event, state, distanceRows) + if (trackpadReportCount !== null) { + return trackpadReportCount + } + const rows = Math.min( TUI_WHEEL_BURST_MAX_DISTANCE_ROWS_PER_EVENT, diff --git a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs index 207aac49cfc..137bc48dee1 100644 --- a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs +++ b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs @@ -1,6 +1,20 @@ +const fs = require('node:fs') + const ESC = '\x1b' const MOUSE_REPORT_PATTERN = new RegExp(`${ESC}\\[<(64|65);\\d+;\\d+M`, 'g') +// Optional flags: +// --log append " " per consumed stdin +// chunk so tests can measure when reports reached the PTY. +// --heavy emit Claude-Code-scale redraw frames (~4KB of styled cells +// per render, ~430KB/s at 120 reports/s — matches output +// volume measured from a real Claude Code session scrolling +// at 120 reports/s) instead of minimal rows. +const args = process.argv.slice(2) +const logIndex = args.indexOf('--log') +const LOG_PATH = logIndex >= 0 ? args[logIndex + 1] : null +const HEAVY_FRAMES = args.includes('--heavy') + let offset = 0 let pending = '' @@ -12,12 +26,28 @@ function visibleRows() { return Math.max(3, process.stdout.rows || 24) } +function visibleCols() { + return Math.max(20, process.stdout.columns || 80) +} + +function heavyRowFiller(row, cols) { + // ~19 bytes per 8 visible columns of styled filler. + const unit = `${ESC}[38;5;${((row * 17) % 200) + 16}m········` + const units = Math.max(0, Math.floor((cols - 24) / 8)) + return `${unit.repeat(units)}${ESC}[0m` +} + function render() { - write(`${ESC}[H`) - write(`TUI_SCROLL_READY offset=${offset}${ESC}[K`) - for (let row = 1; row < visibleRows(); row += 1) { - write(`\r\nTUI_SCROLL_ROW_${String(offset + row - 1).padStart(4, '0')}${ESC}[K`) + const rows = visibleRows() + const cols = visibleCols() + let frame = `${ESC}[?2026h${ESC}[H` + frame += `TUI_SCROLL_READY offset=${offset}${ESC}[K` + for (let row = 1; row < rows; row += 1) { + const label = `TUI_SCROLL_ROW_${String(offset + row - 1).padStart(4, '0')}` + frame += `\r\n${label}${HEAVY_FRAMES ? ` ${heavyRowFiller(row, cols)}` : ''}${ESC}[K` } + frame += `${ESC}[?2026l` + write(frame) } function cleanup() { @@ -42,14 +72,19 @@ process.stdin.on('data', (chunk) => { pending += chunk let match let lastIndex = 0 + let reportsInChunk = 0 MOUSE_REPORT_PATTERN.lastIndex = 0 while ((match = MOUSE_REPORT_PATTERN.exec(pending)) !== null) { offset = Math.max(0, offset + (match[1] === '65' ? 1 : -1)) + reportsInChunk += 1 lastIndex = MOUSE_REPORT_PATTERN.lastIndex } if (lastIndex > 0) { + if (LOG_PATH) { + fs.appendFileSync(LOG_PATH, `${Date.now()} ${reportsInChunk}\n`) + } pending = pending.slice(lastIndex) render() return diff --git a/tests/e2e/terminal-tui-wheel-drain.spec.ts b/tests/e2e/terminal-tui-wheel-drain.spec.ts new file mode 100644 index 00000000000..c228159852c --- /dev/null +++ b/tests/e2e/terminal-tui-wheel-drain.spec.ts @@ -0,0 +1,195 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +const VISIBLE_TUI_FIXTURE_PATH = path.join( + process.cwd(), + 'tests/e2e/fixtures/visible-tui-scroll-fixture.cjs' +) + +// Reports must stop reaching the PTY shortly after the gesture ends. Before +// the input-write-queue coalescing fix, each SGR report drained in its own +// >=4ms-clamped macrotask turn; a dense trackpad stream delivered through the +// real input pipeline (which outprioritizes timers) plus TUI-scale redraw +// output starved that drain, and the backlog replayed for seconds after the +// fingers left the trackpad. +const MAX_ARRIVAL_LAG_MS = 900 +const WHEEL_EVENTS = 240 + +type WheelStreamResult = { + dispatchedEvents: number + inputEndWallClockMs: number +} + +async function startHeavyTuiFixture(page: Page, logPath: string): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await page.evaluate(() => + window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 1 }) + ) + + const ptyId = await waitForActivePanePtyId(page) + await execInTerminal( + page, + ptyId, + `node ${JSON.stringify(VISIBLE_TUI_FIXTURE_PATH)} --heavy --log ${JSON.stringify(logPath)}` + ) + + await expect + .poll( + () => + page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.terminal.element?.classList.contains('enable-mouse-events') ?? false + }), + { timeout: 15_000, message: 'fixture did not enable mouse reporting' } + ) + .toBe(true) +} + +async function terminalWheelTarget( + page: Page +): Promise<{ x: number; y: number; cellHeight: number }> { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane?.terminal || !screen) { + throw new Error('Active terminal screen unavailable') + } + const rect = screen.getBoundingClientRect() + return { + x: rect.left + rect.width / 2, + y: rect.top + Math.min(rect.height - 1, 40), + cellHeight: + pane.terminal._core?._renderService?.dimensions?.css?.cell?.height ?? + rect.height / pane.terminal.rows + } + }) +} + +/** + * Drives real wheel input (CDP mouse events through the compositor input + * pipeline — the same priority class as physical trackpad input) rather than + * synthetic DOM dispatchEvent, so input can genuinely compete with the + * renderer's timer-based PTY input drain the way a physical gesture does. + */ +async function dispatchTrackpadWheelStream( + page: Page, + options: { alternate: boolean; events: number; deltaY: number } +): Promise { + const target = await terminalWheelTarget(page) + await page.mouse.move(target.x, target.y) + for (let i = 0; i < options.events; i += 1) { + const direction = options.alternate && Math.floor(i / 18) % 2 === 1 ? -1 : 1 + // No artificial sleep: CDP round-trips pace this near real trackpad rates + // while keeping the renderer's input queue continuously occupied. + await page.mouse.wheel(0, direction * options.deltaY) + } + const inputEndWallClockMs = await page.evaluate(() => Date.now()) + return { dispatchedEvents: options.events, inputEndWallClockMs } +} + +type ReportArrival = { atMs: number; reports: number } + +function readReportArrivalLog(logPath: string): ReportArrival[] { + if (!fs.existsSync(logPath)) { + return [] + } + return fs + .readFileSync(logPath, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => { + const [atMs, reports] = line.split(' ') + return { atMs: Number(atMs), reports: Number(reports) } + }) +} + +function summarizeArrivals( + arrivals: ReportArrival[], + input: WheelStreamResult +): { arrivalLagMs: number; chunks: number; totalReports: number } { + const totalReports = arrivals.reduce((sum, entry) => sum + entry.reports, 0) + const lastArrivalMs = arrivals.at(-1)?.atMs ?? 0 + return { + arrivalLagMs: lastArrivalMs - input.inputEndWallClockMs, + chunks: arrivals.length, + totalReports + } +} + +test.describe('terminal TUI wheel report drain', () => { + test('dense trackpad-like wheel stream reaches the PTY while the gesture happens', async ({ + orcaPage + }) => { + const logPath = path.join(os.tmpdir(), `tui-wheel-drain-${Date.now()}.log`) + await startHeavyTuiFixture(orcaPage, logPath) + + const target = await terminalWheelTarget(orcaPage) + const input = await dispatchTrackpadWheelStream(orcaPage, { + alternate: false, + events: WHEEL_EVENTS, + deltaY: Math.min(49, target.cellHeight) + }) + // Give a laggy drain ample time to expose itself before reading the log. + await orcaPage.waitForTimeout(8000) + + const summary = summarizeArrivals(readReportArrivalLog(logPath), input) + fs.rmSync(logPath, { force: true }) + console.log(`[tui-wheel-drain] dense: ${JSON.stringify(summary)}`) + + // The full gesture distance must reach the TUI (no dead/eaten scrolls)... + expect(summary.totalReports, JSON.stringify(summary)).toBeGreaterThanOrEqual(WHEEL_EVENTS - 10) + // ...while the gesture happens, not replayed 1-by-1 afterwards. + expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS) + }) + + test('aggressive alternating trackpad-like gesture does not replay after input ends', async ({ + orcaPage + }) => { + const logPath = path.join(os.tmpdir(), `tui-wheel-drain-alt-${Date.now()}.log`) + await startHeavyTuiFixture(orcaPage, logPath) + + const target = await terminalWheelTarget(orcaPage) + const input = await dispatchTrackpadWheelStream(orcaPage, { + alternate: true, + events: WHEEL_EVENTS, + deltaY: Math.min(49, target.cellHeight) + }) + await orcaPage.waitForTimeout(8000) + + const summary = summarizeArrivals(readReportArrivalLog(logPath), input) + fs.rmSync(logPath, { force: true }) + console.log(`[tui-wheel-drain] alternate: ${JSON.stringify(summary)}`) + + expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS) + }) +}) From 8e1ce20289c6a5d835fb0cd25f1fd5c34d067938 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:44:20 +0000 Subject: [PATCH 58/70] release: v1.4.120-rc.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e37d338f40..ce09f6ab7b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120-rc.5", + "version": "1.4.120-rc.6", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 0ab4d8925c52d259eeb205f7bea87509f5031883 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 07:06:43 +0000 Subject: [PATCH 59/70] release: v1.4.120 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ce09f6ab7b8..60184c71afe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120-rc.6", + "version": "1.4.120", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From f1bcb7739286a5006124d68c85f0fbe5c8de91d0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:16:19 -0700 Subject: [PATCH 60/70] fix: parse Claude Fable current-week usage Claude Code 2.1.199 renders the Fable plan bucket as Current week (Fable), while Orca only parsed a standalone Fable heading. Accept weekly-style Fable headings as the distinct Fable weekly meter while keeping broader Fable-weekly copy as a section boundary to avoid false positives. Validation: - pnpm exec vitest run --config config/vitest.config.ts src/main/rate-limits/claude-pty.test.ts src/main/rate-limits/claude-fetcher.test.ts src/main/rate-limits/service.test.ts src/renderer/src/components/status-bar/tooltip.test.ts src/renderer/src/components/status-bar/inline-usage-bars.test.tsx - pnpm run typecheck - pnpm exec oxlint src/main/rate-limits/claude-pty.ts src/main/rate-limits/claude-pty.test.ts src/main/rate-limits/claude-fetcher.ts src/main/rate-limits/claude-fetcher.test.ts src/main/rate-limits/service.ts src/main/rate-limits/service.test.ts src/renderer/src/components/status-bar/tooltip.test.ts src/renderer/src/components/status-bar/inline-usage-bars.test.tsx - git diff --check HEAD~1..HEAD - live local Claude fetcher returned fableWeekly: 62 --- src/main/rate-limits/claude-pty.test.ts | 43 +++++++++++++++++++++++++ src/main/rate-limits/claude-pty.ts | 12 +++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index a613194868e..acea13231c7 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -258,6 +258,49 @@ describe('fetchViaPty', () => { }) }) + it('parses Claude current-week Fable usage as a distinct weekly window', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + 8% used + Resets 3:39am + + Current week (all models) + 33% used + Resets Jul 3 at 12:59pm + + Current week (Fable) + 62% used + Resets Jul 3 at 12:59pm + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 8, + resetDescription: '3:39am' + }, + weekly: { + usedPercent: 33, + resetDescription: 'Jul 3 at 12:59pm' + }, + fableWeekly: { + usedPercent: 62, + resetDescription: 'Jul 3 at 12:59pm' + }, + error: null + }) + }) + it('does not let an incomplete Fable section consume later usage sections', async () => { const term = makeMockTerm() spawnMock.mockReturnValue(term) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index f0363eba3f2..20919a391ca 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -22,6 +22,8 @@ const SESSION_RE = /current\s*session/i const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i const FABLE_WORD_RE = /\bfable\b/i const FABLE_LABEL_RE = /^\s*fable\s*$/i +const FABLE_WEEKLY_LABEL_RE = + /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)\s*(?:\([^)]*\bfable\b[^)]*\)|[-:]?\s*\bfable\b)/i const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i const ESC = String.fromCharCode(27) @@ -45,6 +47,12 @@ function matchesFableBoundary(line: string): boolean { return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) } +function matchesFableUsageLabel(line: string): boolean { + // Why: broad Fable-weekly copy should stop nearby scans, but only a real + // Fable usage heading should produce the distinct Fable meter. + return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) +} + function isSectionLabel(line: string): boolean { return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) } @@ -104,7 +112,7 @@ function parsePtyUsage(output: string): { const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) - const fableWeeklyPct = extractPercentAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) + const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) const session: RateLimitWindow | null = sessionPct !== null @@ -132,7 +140,7 @@ function parsePtyUsage(output: string): { usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), windowMinutes: 10080, resetsAt: null, - resetDescription: extractResetAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) + resetDescription: extractResetAfterLabel(lines, matchesFableUsageLabel) } : null From 0ff2c090029ad0f86429dd89ac34a1bb6e45afad Mon Sep 17 00:00:00 2001 From: Dvitash Date: Fri, 3 Jul 2026 03:26:37 -0400 Subject: [PATCH 61/70] Fix typed OMP remote title flicker (#6954) --- src/main/runtime/orca-runtime.test.ts | 297 +++++++++++ src/main/runtime/orca-runtime.ts | 119 ++++- .../terminal-pane/pty-connection.test.ts | 467 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 236 ++++++++- src/shared/agent-detection.test.ts | 9 + src/shared/agent-title-owner.ts | 8 + 6 files changed, 1108 insertions(+), 28 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ee8d6edd608..8b3a65720c1 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -12262,6 +12262,303 @@ describe('OrcaRuntimeService', () => { expect(getForegroundProcess).toHaveBeenCalledTimes(2) }) + it('normalizes Pi-compatible mobile session status to OMP for an unknown-launch foreground omp PTY', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(async () => 'omp') + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp') + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ) + expect(result.tabs[0]).not.toHaveProperty('launchAgent') + }) + + it('waits for unknown-launch foreground owner before publishing Pi-compatible mobile status', async () => { + const foregroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(() => foregroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp') + expect(events).toHaveLength(0) + + foregroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('keeps same-status Pi-compatible title changes queued behind the foreground owner probe', async () => { + const foregroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(() => foregroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + foregroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('coalesces same-status title frames behind one post-title foreground probe', async () => { + const staleForegroundProcess = deferred() + const freshForegroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi + .fn() + .mockReturnValueOnce(staleForegroundProcess.promise) + .mockReturnValueOnce(freshForegroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi done\x07', 125) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + staleForegroundProcess.resolve(null) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toHaveLength(0) + + freshForegroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('starts a post-title foreground probe when an older pending probe finds no owner', async () => { + const staleForegroundProcess = deferred() + const freshForegroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi + .fn() + .mockReturnValueOnce(staleForegroundProcess.promise) + .mockReturnValueOnce(freshForegroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + ;( + runtime as unknown as { + refreshPtyForegroundAgentFromController: (ptyId: string) => Promise + } + ).refreshPtyForegroundAgentFromController('pty-typed-omp') + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + staleForegroundProcess.resolve(null) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toHaveLength(0) + + freshForegroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('keeps Pi-compatible mobile session status as Pi for an unknown-launch foreground pi PTY', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-pi' }) + const getForegroundProcess = vi.fn(async () => 'pi') + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-pi-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + runtime.onPtyData('pty-typed-pi', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-pi') + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'Pi ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'pi', + terminalHandle: terminal.handle, + terminalTitle: 'Pi ready' + }) + }) + ) + expect(result.tabs[0]).not.toHaveProperty('launchAgent') + }) + it('keeps renderer-vetted mobile agent status for custom-titled terminals', async () => { const runtime = new OrcaRuntimeService(store) const leafId = '11111111-1111-4111-8111-111111111111' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 38414da2155..bbfa9731182 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -20,6 +20,7 @@ import { type AgentStatusEntry } from '../../shared/agent-status-types' import { + hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner } from '../../shared/agent-title-owner' @@ -942,6 +943,12 @@ type RuntimePtyWorktreeRecord = { waitBlockedAt: number | null } +type PtyForegroundAgentRefresh = { + promise: Promise + startedAfterTitleObservation: number + requestedAfterTitleObservation: number +} + function copySleepingAgentLaunchConfig( config: SleepingAgentLaunchConfig ): SleepingAgentLaunchConfig { @@ -1925,7 +1932,8 @@ export class OrcaRuntimeService { private resolvedWorktreeGeneration = 0 private cloneInFlightByPath = new Map>() private agentDetector: AgentDetector | null = null - private ptyForegroundAgentRefreshes = new Map>() + private ptyForegroundAgentRefreshes = new Map() + private ptyDelayedForegroundSnapshotTitleObservations = new Map() private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() // Why: mobile clients subscribe to terminal output via terminal.subscribe. @@ -3253,7 +3261,7 @@ export class OrcaRuntimeService { /** * Publishes a PTY-backed terminal tab snapshot to the synced mobile session, - * normalizing Pi-compatible titles based on launch ownership. + * normalizing Pi-compatible titles based on launch or foreground ownership. */ private publishPtyBackedMobileSessionTerminal( worktreeId: string, @@ -3269,9 +3277,10 @@ export class OrcaRuntimeService { } ): void { const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + const ownerAgent = pty.launchAgent ?? pty.foregroundAgent const title = normalizeCompatibleAgentTitleForOwner( args.title ?? getLatestPtyTitle(pty) ?? 'Terminal', - pty.launchAgent + ownerAgent ) const existingTab = existing?.tabs.find( (candidate): candidate is RuntimeMobileSessionTerminalTab => @@ -5194,11 +5203,25 @@ export class OrcaRuntimeService { if (agentStatus === 'idle' && prevStatus !== 'idle') { this.resolvePtyTuiIdleWaiters(pty, ptyId) } + const shouldDelayMobileSnapshot = + shouldTouchPtyBackedSessionTabs && + this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, oscTitle) + let foregroundRefresh: Promise | undefined // Why: gate on an actual status transition — braille spinner frames // mutate the title every tick, so probing per-title-change would stream // a foreground query per frame during active work. if (prevStatus !== pty.lastAgentStatus) { - this.refreshPtyForegroundAgent(ptyId) + foregroundRefresh = this.refreshPtyForegroundAgentFromController(ptyId, { + afterTitleObservation: observedAt + }) + } else if (shouldDelayMobileSnapshot) { + // Why: same-status compatible title changes can arrive before the + // foreground owner probe settles; publishing them would flicker. + foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt) + } + if (foregroundRefresh && shouldDelayMobileSnapshot) { + shouldTouchPtyBackedSessionTabs = false + this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) } } } @@ -8718,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. @@ -8726,19 +8758,75 @@ export class OrcaRuntimeService { void this.refreshPtyForegroundAgentFromController(ptyId) } + private getPendingForegroundAgentRefreshForTitle( + ptyId: string, + titleObservedAt: number + ): Promise | undefined { + if (!this.ptyForegroundAgentRefreshes.has(ptyId)) { + return undefined + } + return this.refreshPtyForegroundAgentFromController(ptyId, { + afterTitleObservation: titleObservedAt + }) + } + + private delayPtyBackedMobileSnapshotForForegroundAgent( + ptyId: string, + titleObservedAt: number, + foregroundRefresh: Promise + ): void { + this.ptyDelayedForegroundSnapshotTitleObservations.set(ptyId, titleObservedAt) + void foregroundRefresh.then((foregroundAgentChanged) => { + if (this.ptyDelayedForegroundSnapshotTitleObservations.get(ptyId) !== titleObservedAt) { + return + } + this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) + if (!foregroundAgentChanged) { + this.touchMobileSessionSnapshotsForPty(ptyId) + } + }) + } + /** * Deduplicates and manages in-flight foreground agent refresh queries * for a specific PTY. */ - private refreshPtyForegroundAgentFromController(ptyId: string): Promise { + private refreshPtyForegroundAgentFromController( + ptyId: string, + options: { afterTitleObservation?: number } = {} + ): Promise { + const startedAfterTitleObservation = options.afterTitleObservation ?? 0 const pendingRefresh = this.ptyForegroundAgentRefreshes.get(ptyId) if (pendingRefresh) { - return pendingRefresh + pendingRefresh.requestedAfterTitleObservation = Math.max( + pendingRefresh.requestedAfterTitleObservation, + startedAfterTitleObservation + ) + return pendingRefresh.promise } - const refresh = this.loadPtyForegroundAgentFromController(ptyId).finally(() => { - this.ptyForegroundAgentRefreshes.delete(ptyId) + const entry: PtyForegroundAgentRefresh = { + promise: Promise.resolve(false), + startedAfterTitleObservation, + requestedAfterTitleObservation: startedAfterTitleObservation + } + const refresh = (async (): Promise => { + while (true) { + entry.startedAfterTitleObservation = entry.requestedAfterTitleObservation + const foregroundAgentChanged = await this.loadPtyForegroundAgentFromController(ptyId) + if ( + foregroundAgentChanged || + entry.requestedAfterTitleObservation <= entry.startedAfterTitleObservation + ) { + return foregroundAgentChanged + } + } + })().finally(() => { + if (this.ptyForegroundAgentRefreshes.get(ptyId) === entry) { + this.ptyForegroundAgentRefreshes.delete(ptyId) + } }) - this.ptyForegroundAgentRefreshes.set(ptyId, refresh) + entry.promise = refresh + this.ptyForegroundAgentRefreshes.set(ptyId, entry) return refresh } @@ -8746,34 +8834,35 @@ export class OrcaRuntimeService { * Queries the PTY controller for the active foreground process, identifies if it * is a recognized agent, and updates the PTY's foreground agent state if changed. */ - private async loadPtyForegroundAgentFromController(ptyId: string): Promise { + private async loadPtyForegroundAgentFromController(ptyId: string): Promise { if (!this.ptyController) { - return + return false } const pty = this.ptysById.get(ptyId) if (!pty?.connected) { - return + return false } // Why: foregroundAgent is only consulted as the owner fallback when // launchAgent is unknown, so a known launchAgent makes the relay // getForegroundProcess round-trip pure waste (covers all launched agents). if (pty.launchAgent) { - return + return false } let foregroundProcess: string | null try { foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) } catch { - return + return false } const foregroundAgent = foregroundProcess ? (recognizeAgentProcess(foregroundProcess)?.agent ?? null) : null if (pty.foregroundAgent === foregroundAgent) { - return + return false } pty.foregroundAgent = foregroundAgent this.touchMobileSessionSnapshotsForPty(ptyId) + return true } private getFreshExplicitAgentStatusForHandle(handle: string): { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 53da6899409..4b3b796d5a4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -230,6 +230,9 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { if (/Codex( working)?/.test(title)) { return /working/.test(title) ? 'working' : 'idle' } + if (/^\s*(?:[\u2800-\u28ff]\s+)?(?:Pi|OMP)(?: ready| idle)?\s*$/i.test(title)) { + return /[\u2800-\u28ff]/u.test(title) ? 'working' : 'idle' + } return null }) } @@ -726,15 +729,18 @@ describe('connectPanePty', () => { markWorktreeUnread: vi.fn(), observeTerminalGitHubPullRequestLink: vi.fn(), recordTerminalInput: vi.fn(), - setAgentStatus: vi.fn((paneKey: string, payload: Record) => { - mockStoreState.agentStatusByPaneKey[paneKey] = { - ...payload, - paneKey, - updatedAt: Date.now(), - stateStartedAt: Date.now(), - stateHistory: [] + setAgentStatus: vi.fn( + (paneKey: string, payload: Record, terminalTitle?: string | null) => { + mockStoreState.agentStatusByPaneKey[paneKey] = { + ...payload, + paneKey, + ...(terminalTitle ? { terminalTitle } : {}), + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } } - }), + ), removeAgentStatus: vi.fn(), dropAgentStatus: vi.fn(), markTerminalTabUnread: vi.fn(), @@ -924,6 +930,451 @@ describe('connectPanePty', () => { ) }) + it('normalizes Pi-compatible remote runtime status to OMP after typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-omp') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('normalizes after shell word deletion edits a typed command to omp', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-omp-edited') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'pi \x17omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('keeps Pi-compatible remote runtime status as Pi after typed pi command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'pi\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('pi\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('does not infer shell ownership from prompts typed inside an existing Pi session', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: '', + agentType: 'pi', + paneKey, + terminalTitle: 'Pi ready', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi-prompt') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('does not infer shell ownership from prompts typed in a title-only Pi session', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi-title-only') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('lets a new typed omp command override a stale retained done status', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: '', + agentType: 'pi', + paneKey, + terminalTitle: 'Pi ready', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-stale-done') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('tracks cursor edits when inferring a typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-cursor-edit') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'op\x1b[Dm\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('tracks delete-key cursor edits when inferring a typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-delete-edit') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'ommp\x1b[D\x1b[D\x1b[3~\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('skips manual agent inference for large paste chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-large-paste') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, `${'x'.repeat(4097)}omp\r`) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('resumes manual agent inference when large paste input is cancelled', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-cancelled-large-paste') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'x'.repeat(4097)) + sendTerminalInputThroughPane(pane, '\x03') + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('preserves typed shell ownership through same-chunk command-finished side effects', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-command-finished') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks?.onData ?? null + return 'remote:web-env-1@@pty-command-finished' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + const dataCallback = dataCallbackRef.current + if (!dataCallback || !onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + dataCallback('\x1b]133;D;0\x07') + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + it('queues visible bulk output off the synchronous xterm write path', async () => { const { connectPanePty } = await import('./pty-connection') const pane = createPane(1) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f614aea121b..582d695c322 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -89,7 +89,11 @@ import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stabl import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { e2eConfig } from '@/lib/e2e-config' -import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentType +} from '../../../../shared/agent-status-types' import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import { createAgentInterruptInference, @@ -156,6 +160,10 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +import { + isExpectedAgentProcess, + recognizeAgentProcessFromCommandLine +} from '../../../../shared/agent-process-recognition' import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types' import { isWslUncPath } from '../../../../shared/wsl-paths' import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' @@ -165,7 +173,6 @@ import { beginAgentStartupDeliveryAttempt, releaseAgentStartupDeliveryAttempt } from '@/lib/agent-startup-delayed-delivery' -import { isExpectedAgentProcess } from '../../../../shared/agent-process-recognition' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -176,6 +183,7 @@ const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500 const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000 const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500 const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500 +const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096 const STARTUP_DRAFT_PASTE_QUIET_MS = 1500 const STARTUP_DRAFT_PASTE_TIMEOUT_MS = 8000 const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 @@ -1238,12 +1246,219 @@ export function connectPanePty( } /** * Resolves the authoritative owner agent type for this pane, checking tab launch, - * pane startup, and store state configuration. + * pane startup, typed command ownership, and store state configuration. * * Why: launch ownership wins so Pi-compatible live titles/hooks can't repaint an - * OMP-owned pane back to Pi; the stored status agentType is only the last-resort - * fallback because it can itself be a Pi-compatible frame. + * OMP-owned pane back to Pi; command ownership covers manually typed `omp` + * in generic terminals where launch metadata does not exist. */ + let commandInferredPaneAgent: TuiAgent | null = null + let pendingShellCommandLine = '' + let pendingShellCommandCursor = 0 + let commandInferredPaneAgentGeneration = 0 + let shellCommandInferenceSuspendedUntilCommandEnd = false + const resetPendingShellCommandLine = (): void => { + pendingShellCommandLine = '' + pendingShellCommandCursor = 0 + } + const rememberCommandInferredPaneAgent = (): void => { + const commandLine = pendingShellCommandLine.trim() + resetPendingShellCommandLine() + const nextAgent = commandLine + ? (recognizeAgentProcessFromCommandLine(commandLine)?.agent ?? null) + : null + commandInferredPaneAgent = nextAgent + commandInferredPaneAgentGeneration += 1 + } + const clearCommandInferredPaneAgent = (): void => { + commandInferredPaneAgent = null + resetPendingShellCommandLine() + commandInferredPaneAgentGeneration += 1 + } + const clearCommandInferredPaneAgentAfterPtySideEffects = (): void => { + const generation = commandInferredPaneAgentGeneration + resetPendingShellCommandLine() + queueMicrotask(() => { + setTimeout(() => { + if (commandInferredPaneAgentGeneration === generation) { + clearCommandInferredPaneAgent() + } + }, 0) + }) + } + const appendPendingShellCommandInput = (text: string): void => { + const available = MANUAL_AGENT_COMMAND_MAX_CHARS - pendingShellCommandLine.length + if (available <= 0) { + shellCommandInferenceSuspendedUntilCommandEnd = true + return + } + const inserted = text.slice(0, available) + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor) + + inserted + + pendingShellCommandLine.slice(pendingShellCommandCursor) + pendingShellCommandCursor += inserted.length + if (inserted.length < text.length) { + shellCommandInferenceSuspendedUntilCommandEnd = true + } + } + const deletePendingShellCommandWord = (): void => { + const beforeCursor = pendingShellCommandLine.slice(0, pendingShellCommandCursor) + const afterCursor = pendingShellCommandLine.slice(pendingShellCommandCursor) + const nextBeforeCursor = beforeCursor.replace(/[^\S\r\n]*\S+[^\S\r\n]*$/, '') + pendingShellCommandLine = nextBeforeCursor + afterCursor + pendingShellCommandCursor = nextBeforeCursor.length + } + const cancelSuspendedShellCommandInference = (): void => { + if (!shellCommandInferenceSuspendedUntilCommandEnd) { + return + } + shellCommandInferenceSuspendedUntilCommandEnd = false + resetPendingShellCommandLine() + } + const deletePendingShellCommandCharacter = (): void => { + if (pendingShellCommandCursor === 0) { + return + } + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor - 1) + + pendingShellCommandLine.slice(pendingShellCommandCursor) + pendingShellCommandCursor -= 1 + } + const deletePendingShellCommandCharacterAtCursor = (): void => { + if (pendingShellCommandCursor >= pendingShellCommandLine.length) { + return + } + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor) + + pendingShellCommandLine.slice(pendingShellCommandCursor + 1) + } + const movePendingShellCommandCursor = (delta: number): void => { + pendingShellCommandCursor = Math.min( + pendingShellCommandLine.length, + Math.max(0, pendingShellCommandCursor + delta) + ) + } + const consumeShellCommandCsiSequence = (data: string, index: number): number | null => { + if (data.charCodeAt(index) !== 0x1b || data[index + 1] !== '[') { + return null + } + let cursor = index + 2 + while (cursor < data.length && /[0-9;?]/.test(data[cursor]!)) { + cursor += 1 + } + const final = data[cursor] + if (!final || !/[~A-Za-z]/.test(final)) { + return null + } + const params = data.slice(index + 2, cursor) + if (final === 'D') { + movePendingShellCommandCursor(-1) + } else if (final === 'C') { + movePendingShellCommandCursor(1) + } else if (final === 'H' || (final === '~' && params === '1')) { + pendingShellCommandCursor = 0 + } else if (final === 'F' || (final === '~' && params === '4')) { + pendingShellCommandCursor = pendingShellCommandLine.length + } else if (final === '~' && params === '3') { + deletePendingShellCommandCharacterAtCursor() + } else if (final === '~' && (params === '200' || params === '201')) { + // Bracketed paste wrappers are terminal framing, not shell command text. + } else { + resetPendingShellCommandLine() + } + return cursor + 1 + } + const getLivePaneAgentTitle = (): string | null => { + const state = useAppStore.getState() + const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (entry) => entry.id === deps.tabId + )?.title + return runtimeTitle ?? tabTitle ?? null + } + const hasFreshPaneAgentSurface = (): boolean => { + const state = useAppStore.getState() + const entry = state.agentStatusByPaneKey[cacheKey] + const now = Date.now() + const entryIsFresh = + entry && + typeof entry.updatedAt === 'number' && + now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS + if (entryIsFresh && entry.state !== 'done') { + return true + } + const liveTitle = getLivePaneAgentTitle() + return detectAgentStatusFromTitle(liveTitle ?? '') !== null + } + const observeAcceptedShellCommandInput = (data: string): void => { + if (commandInferredPaneAgent) { + return + } + // Why: bytes typed inside a live agent TUI are prompt text, not shell + // commands, even if they spell another agent binary name. + if (hasFreshPaneAgentSurface()) { + resetPendingShellCommandLine() + return + } + if (shellCommandInferenceSuspendedUntilCommandEnd) { + if (data.includes('\x03') || data.includes('\x15')) { + shellCommandInferenceSuspendedUntilCommandEnd = false + resetPendingShellCommandLine() + } + if (data.includes('\r') || data.includes('\n')) { + shellCommandInferenceSuspendedUntilCommandEnd = false + } + return + } + if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) { + resetPendingShellCommandLine() + shellCommandInferenceSuspendedUntilCommandEnd = !data.includes('\r') && !data.includes('\n') + return + } + for (let index = 0; index < data.length; index += 1) { + const char = data[index]! + if (char === '\r' || char === '\n') { + shellCommandInferenceSuspendedUntilCommandEnd = false + rememberCommandInferredPaneAgent() + if (commandInferredPaneAgent) { + return + } + continue + } + if (char === '\x7f' || char === '\b') { + deletePendingShellCommandCharacter() + continue + } + if (char === '\x17') { + deletePendingShellCommandWord() + continue + } + if (char === '\x03' || char === '\x15') { + resetPendingShellCommandLine() + continue + } + if (char === '\x1b') { + const nextIndex = consumeShellCommandCsiSequence(data, index) + if (nextIndex !== null) { + index = nextIndex - 1 + continue + } + resetPendingShellCommandLine() + continue + } + if (char < ' ') { + resetPendingShellCommandLine() + continue + } + if (char >= ' ') { + appendPendingShellCommandInput(char) + if (shellCommandInferenceSuspendedUntilCommandEnd) { + return + } + } + } + } const getAuthoritativePaneAgent = (): AgentType | undefined => { const state = useAppStore.getState() const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( @@ -1253,6 +1468,7 @@ export function connectPanePty( tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent ?? + commandInferredPaneAgent ?? state.agentStatusByPaneKey[cacheKey]?.agentType ) } @@ -1522,6 +1738,7 @@ export function connectPanePty( } const commandLifecycle = createTerminalCommandLifecycle({ onCommandFinished: () => { + clearCommandInferredPaneAgentAfterPtySideEffects() // Why: the finished command may have moved HEAD or the index (e.g. // `git checkout`); nudge git UI now instead of waiting for a poll. dispatchTerminalCommandFinishedEvent(deps.worktreeId) @@ -2192,6 +2409,7 @@ export function connectPanePty( } } const onAgentExited = (): void => { + clearCommandInferredPaneAgent() // Why: when the terminal title reverts to a plain shell (e.g., "bash", "zsh"), // the agent has exited. Clear any running cache timer so the sidebar doesn't // show a stale countdown for a tab that no longer has an active Claude session. @@ -2461,6 +2679,11 @@ export function connectPanePty( // excluded because those transports do not expose sendInputAccepted. const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data) if (acknowledgedIntent && transport.sendInputAccepted) { + if (acknowledgedIntent === 'ctrl-c') { + // Why: the accepted-write callback is async; let the next command be + // inferred if the user cancelled an oversized line and immediately typed. + cancelSuspendedShellCommandInference() + } clearPendingTerminalInputIntent() markTerminalInputSent() const writePromise = transport @@ -2468,6 +2691,7 @@ export function connectPanePty( .then((accepted) => { if (accepted) { recordAcceptedTerminalInputForHibernation() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, acknowledgedIntent) interruptInference.observeInputIntent(acknowledgedIntent) observeTitleOnlyInterrupt() @@ -2482,6 +2706,7 @@ export function connectPanePty( if (intent) { if (transport.sendInput(data)) { markAcceptedTerminalInputSent() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, intent) } clearPendingTerminalInputIntent() @@ -2489,6 +2714,7 @@ export function connectPanePty( } if (transport.sendInput(data)) { markAcceptedTerminalInputSent() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data) observeSentTerminalInputIntent(data) } else { diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index b4f8495548a..a1bee91aef3 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -8,6 +8,7 @@ import { MAX_OSC_TITLE_CHARS } from './agent-detection' import { + hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner @@ -114,6 +115,14 @@ describe('Pi-compatible title detection', () => { expect(normalizeCompatibleAgentTitleForOwner('\u280b Pi', 'codex')).toBe('\u280b Pi') }) + it('identifies titles whose compatible identity can be re-owned', () => { + expect(hasCompatibleAgentTitleIdentity('Pi ready')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('π - tmp')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('\u280b OMP')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('Fix pi bugs')).toBe(false) + expect(hasCompatibleAgentTitleIdentity('\u280b Codex')).toBe(false) + }) + it('normalizes Pi-compatible status identity and terminal title to the owner', () => { const status = normalizeCompatibleAgentStatusEntryForOwner( { diff --git a/src/shared/agent-title-owner.ts b/src/shared/agent-title-owner.ts index a72a46b1cb8..4e1dfda55af 100644 --- a/src/shared/agent-title-owner.ts +++ b/src/shared/agent-title-owner.ts @@ -43,6 +43,14 @@ function getProfileForTitle(title: string): TitleProfileMatch | null { return null } +/** + * Why: unknown-launch remote sessions need to wait for foreground ownership + * before publishing title frames whose identity can be re-owned. + */ +export function hasCompatibleAgentTitleIdentity(title: string): boolean { + return Boolean(getProfileForTitle(title)?.profile.titleIdentityGroup) +} + /** * Detects the agent status (working, permission, idle) from a terminal title, * accounting for legacy Pi titles. From 471881c14f07665ca17a596b11cd46d910904b7b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:06:13 -0700 Subject: [PATCH 62/70] Update WeChat QR code --- docs/assets/wechat-qr.png | Bin 62128 -> 443867 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/wechat-qr.png b/docs/assets/wechat-qr.png index 3c71c4952ea643ab6b2ac1233ffab84890d7d3a1..bf270d857e800cc3f19c4375f2e89140e30efee7 100644 GIT binary patch literal 443867 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

mVSHw72pz-@?~w3_Cb0iSjjPlQZCiY*#AyV! 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 z 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%0Dbc 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 z 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
{ - if (e.button !== 0) { - return - } - onActivate() - listeners?.onPointerDown?.(e) + onTabPointerDown( + e, + listeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onMouseDown={(e) => { if (e.button === 1) { diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 3fb1a28ca5d..9de55f9c95e 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -30,6 +30,7 @@ import { EditorFileTabContextMenu } from './EditorFileTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { EditorFileTabCloseButton } from './EditorFileTabCloseButton' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' export default function EditorFileTab({ file, @@ -201,6 +202,12 @@ export default function EditorFileTab({ }, [menuOpen]) const dragListeners = isRenaming ? undefined : listeners + // Why: defer activation to pointer-up so dragging the tab (reorder / move into + // another pane / split) does not switch the active tab mid-gesture. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ + onActivate, + disabled: isRenaming + }) const tabRoot = (
{ - if (isRenaming || e.button !== 0) { - return - } - onActivate() - dragListeners?.onPointerDown?.(e) + onTabPointerDown( + e, + dragListeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onDoubleClick={() => { if (file.isPreview && onMakePermanent) { diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 3bef8529a75..fa90c59e553 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -25,6 +25,7 @@ import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' type SortableTabProps = { tab: TerminalTab @@ -210,6 +211,16 @@ export default function SortableTab({ // so dnd-kit's a11y attributes (aria-roledescription, etc.) remain on the element — only // the pointer listeners are gated so a drag can't start while typing. const dragListeners = isEditing ? undefined : listeners + const handleActivate = useCallback(() => { + onActivate(tab.id) + }, [onActivate, tab.id]) + // Why: defer activation to pointer-up so pressing a tab to drag it (reorder / + // move into another pane / split) does not switch the active tab or steal + // terminal focus mid-gesture. See tab-strip-pointer-activation. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ + onActivate: handleActivate, + disabled: isEditing + }) const closeShortcut = useShortcutKeyDetails('tab.close') const tabTitle = tab.customTitle ?? tab.title const tabRoot = ( @@ -243,11 +254,10 @@ export default function SortableTab({ handleRenameOpen() }} onPointerDown={(e) => { - if (isEditing || e.button !== 0) { - return - } - onActivate(tab.id) - dragListeners?.onPointerDown?.(e) + onTabPointerDown( + e, + dragListeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onMouseDown={(e) => { // Why: prevent default browser middle-click behavior (auto-scroll) diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx new file mode 100644 index 00000000000..3bd467e49b0 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TAB_DRAG_ACTIVATION_DISTANCE_PX } from '../tab-group/useTabDragSplit' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' + +function pointerDownEvent(clientX: number, clientY: number, button = 0): React.PointerEvent { + return { button, clientX, clientY } as unknown as React.PointerEvent +} + +function firePointer(type: string, clientX: number, clientY: number): void { + act(() => { + window.dispatchEvent(new PointerEvent(type, { clientX, clientY, bubbles: true })) + }) +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('useTabStripPointerActivation', () => { + it('activates on a release that never crossed the drag threshold (a click)', () => { + const onActivate = vi.fn() + const dragListener = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10), dragListener)) + // Why: the dnd-kit gesture must start on pointerdown even though activation + // is deferred. + expect(dragListener).toHaveBeenCalledTimes(1) + expect(onActivate).not.toHaveBeenCalled() + + // Release within the threshold -> click -> activate. + firePointer('pointerup', 12, 11) + expect(onActivate).toHaveBeenCalledTimes(1) + }) + + it('suppresses activation when the pointer travels past the drag threshold', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointermove', 10 + TAB_DRAG_ACTIVATION_DISTANCE_PX + 5, 10) + firePointer('pointerup', 300, 40) + + expect(onActivate).not.toHaveBeenCalled() + }) + + it('stays a drag even if the pointer returns near the start before release', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + // Cross the threshold, then come back over the origin and release. + firePointer('pointermove', 200, 200) + firePointer('pointerup', 11, 11) + + expect(onActivate).not.toHaveBeenCalled() + }) + + it('does not activate when the press is cancelled', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointercancel', 10, 10) + firePointer('pointerup', 10, 10) + + expect(onActivate).not.toHaveBeenCalled() + }) + + it('ignores non-left buttons and disabled presses', () => { + const onActivate = vi.fn() + const dragListener = vi.fn() + const { result, rerender } = renderHook( + ({ disabled }: { disabled: boolean }) => + useTabStripPointerActivation({ onActivate, disabled }), + { initialProps: { disabled: false } } + ) + + // Right-click: ignored. + act(() => result.current.onPointerDown(pointerDownEvent(10, 10, 2), dragListener)) + firePointer('pointerup', 10, 10) + expect(onActivate).not.toHaveBeenCalled() + expect(dragListener).not.toHaveBeenCalled() + + // Disabled: ignored. + rerender({ disabled: true }) + act(() => result.current.onPointerDown(pointerDownEvent(10, 10), dragListener)) + firePointer('pointerup', 10, 10) + expect(onActivate).not.toHaveBeenCalled() + expect(dragListener).not.toHaveBeenCalled() + }) + + it('activates a click that lands after a prior drag gesture (regression #6395)', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + // First gesture: a drag (reorder). No activation. + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointermove', 300, 10) + firePointer('pointerup', 300, 10) + expect(onActivate).not.toHaveBeenCalled() + + // Second gesture: a plain click. Must activate. + act(() => result.current.onPointerDown(pointerDownEvent(400, 10))) + firePointer('pointerup', 400, 10) + expect(onActivate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts new file mode 100644 index 00000000000..e1a32e1e0d8 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef } from 'react' +import { TAB_DRAG_ACTIVATION_DISTANCE_PX } from '../tab-group/useTabDragSplit' + +/** + * Defer tab activation to pointer-up and suppress it when the press turns into a + * drag. PR #5927 shipped this so dragging a tab (to reorder, move into another + * pane, or split) never switched the active tab or stole terminal focus + * mid-gesture; #6395 removed it (activating eagerly on pointerdown) to fix + * click-to-switch-after-reorder, which regressed the drag feature. + * + * We gate on measured pointer DISPLACEMENT, not the drag-active context ref the + * old hook used — that ref clears asynchronously relative to the drop's + * pointerup, which is what made #6395's click-after-reorder misfire. Displacement + * mirrors dnd-kit's own activation threshold exactly: once the pointer travels + * past it the gesture is a drag (activation suppressed); a release within it is a + * click (activate). Because each press measures its own gesture, a click after a + * reorder always activates. + */ +export function useTabStripPointerActivation({ + onActivate, + disabled = false +}: { + onActivate: () => void + disabled?: boolean +}): { + onPointerDown: ( + event: React.PointerEvent, + dragListener?: (event: React.PointerEvent) => void + ) => void +} { + const onActivateRef = useRef(onActivate) + onActivateRef.current = onActivate + const cleanupRef = useRef<(() => void) | null>(null) + + // Why: a press still holding when the tab unmounts (tab closed mid-drag, group + // collapse) would otherwise leak its window listeners and later fire activation + // on a dead closure. + useEffect(() => () => cleanupRef.current?.(), []) + + const onPointerDown = useCallback( + (event: React.PointerEvent, dragListener?: (event: React.PointerEvent) => void) => { + if (disabled || event.button !== 0) { + return + } + // Why: start the dnd-kit gesture immediately on pointerdown; only the + // activation decision is deferred to release. + dragListener?.(event) + + cleanupRef.current?.() + const startX = event.clientX + const startY = event.clientY + let draggedPastThreshold = false + + const cleanup = (): void => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + cleanupRef.current = null + } + const onPointerMove = (moveEvent: PointerEvent): void => { + if ( + Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) >= + TAB_DRAG_ACTIVATION_DISTANCE_PX + ) { + draggedPastThreshold = true + } + } + const onPointerUp = (): void => { + const wasDrag = draggedPastThreshold + cleanup() + // Why: only a release that never crossed the drag threshold is a click. + // Activating after a real drag would yank the just-dropped tab's pane + // back to the source selection. + if (!wasDrag) { + onActivateRef.current() + } + } + const onPointerCancel = (): void => { + cleanup() + } + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + cleanupRef.current = cleanup + }, + [disabled] + ) + + return { onPointerDown } +} diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 9f85c325a08..57dbf5946fc 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -225,7 +225,9 @@ export default function TabGroupPanel({ user can only drag from the tiny left-sidebar header strip. */}
{/* Why: Electron's native drag hit-test only respects no-drag on DOM diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index b1f90ac2f60..dd5d2116b8c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -15,7 +15,11 @@ import { resolveOpaqueTerminalBackground, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' -import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import type { + ManagedPane, + PaneExternalDropTarget, + PaneManager +} from '@/lib/pane-manager/pane-manager' import TerminalSearch from '@/components/TerminalSearch' import type { PtyTransport } from './pty-transport' import { fitPanes, isWindowsUserAgent } from './pane-helpers' @@ -62,6 +66,11 @@ import { useSystemPrefersDark } from './use-system-prefers-dark' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle' import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu' +import { + detachTerminalPaneToTab, + isTerminalTabStripDropTarget, + resolveTerminalTabStripDropTarget +} from './terminal-pane-tab-detach' import type { PreparedAgentSessionFork } from './terminal-agent-session-fork' import { useNotificationDispatch } from './use-notification-dispatch' import { connectPanePty } from './pty-connection' @@ -1298,6 +1307,54 @@ export default function TerminalPane({ setPendingCloseConfirmation(null) }, []) + const resolveExternalPaneDropTarget = useCallback( + ({ + sourcePaneId, + clientX, + clientY + }: { + sourcePaneId: number + clientX: number + clientY: number + }): PaneExternalDropTarget | null => { + const manager = managerRef.current + const panes = manager?.getPanes() ?? [] + if (panes.length <= 1 || !panes.some((pane) => pane.id === sourcePaneId)) { + return null + } + return resolveTerminalTabStripDropTarget({ + clientX, + clientY, + groupsByWorktree: useAppStore.getState().groupsByWorktree, + worktreeId + }) + }, + [worktreeId] + ) + + const handleExternalPaneDrop = useCallback( + (sourcePaneId: number, target: PaneExternalDropTarget): boolean => { + if (!isTerminalTabStripDropTarget(target)) { + return false + } + const fallbackPtyId = paneTransportsRef.current.get(sourcePaneId)?.getPtyId() ?? null + return ( + detachTerminalPaneToTab({ + fallbackPtyId, + getStore: useAppStore.getState, + manager: managerRef.current, + persistLayoutSnapshot, + sourcePaneId, + sourceTabId: tabId, + targetGroupId: target.groupId, + targetIndex: target.insertionIndex, + worktreeId + }) !== null + ) + }, + [persistLayoutSnapshot, tabId, worktreeId] + ) + useTerminalPaneLifecycle({ tabId, worktreeId, @@ -1354,7 +1411,9 @@ export default function TerminalPane({ paneTitlesRef, setRenamingPaneId, setPaneCount, - setPaneLayoutRevision + setPaneLayoutRevision, + resolveExternalPaneDropTarget, + onExternalPaneDrop: handleExternalPaneDrop }) useEffect(() => { diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts new file mode 100644 index 00000000000..782113e1641 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' + +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' +const LEAF_3 = '33333333-3333-4333-8333-333333333333' + +function splitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { + type: 'split', + direction: 'horizontal', + ratio: 0.7, + first: { type: 'leaf', leafId: LEAF_2 }, + second: { type: 'leaf', leafId: LEAF_3 } + } + }, + activeLeafId: LEAF_2, + expandedLeafId: LEAF_3, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-1', + [LEAF_2]: 'remote:env-1@@terminal-1', + [LEAF_3]: 'pty-3' + }, + buffersByLeafId: { + [LEAF_1]: 'buffer-1', + [LEAF_2]: 'buffer-2', + [LEAF_3]: 'buffer-3' + }, + scrollbackRefsByLeafId: { + [LEAF_1]: 'scrollback-1', + [LEAF_2]: 'scrollback-2', + [LEAF_3]: 'scrollback-3' + }, + titlesByLeafId: { + [LEAF_1]: 'one', + [LEAF_2]: 'remote shell', + [LEAF_3]: 'three' + } + } +} + +describe('detachTerminalLayoutLeaf', () => { + it('extracts a nested leaf into a single-pane layout while preserving SSH PTY state', () => { + const detached = detachTerminalLayoutLeaf(splitLayout(), LEAF_2) + + expect(detached?.ptyId).toBe('remote:env-1@@terminal-1') + expect(detached?.detachedLayout).toEqual({ + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'buffer-2' }, + scrollbackRefsByLeafId: { [LEAF_2]: 'scrollback-2' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(detached?.sourceLayout).toEqual({ + root: { + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_3 } + }, + activeLeafId: LEAF_1, + expandedLeafId: LEAF_3, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-1', + [LEAF_3]: 'pty-3' + }, + buffersByLeafId: { + [LEAF_1]: 'buffer-1', + [LEAF_3]: 'buffer-3' + }, + scrollbackRefsByLeafId: { + [LEAF_1]: 'scrollback-1', + [LEAF_3]: 'scrollback-3' + }, + titlesByLeafId: { + [LEAF_1]: 'one', + [LEAF_3]: 'three' + } + }) + }) + + it('clears source expanded selection when the expanded leaf is detached', () => { + const detached = detachTerminalLayoutLeaf(splitLayout(), LEAF_3) + + expect(detached?.sourceLayout.expandedLeafId).toBeNull() + expect(detached?.sourceLayout.root).toEqual({ + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }) + }) + + it('returns null for missing or only leaf layouts', () => { + expect(detachTerminalLayoutLeaf(splitLayout(), 'missing')).toBeNull() + expect( + detachTerminalLayoutLeaf( + { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-1' } + }, + LEAF_1 + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts new file mode 100644 index 00000000000..d43702057f1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts @@ -0,0 +1,119 @@ +import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' +import { + collectLeafIdsInOrder, + normalizeTerminalLayoutSnapshot, + resolveTerminalLayoutActiveLeafId +} from './terminal-layout-leaf-ids' + +export type DetachedTerminalLayoutLeaf = { + sourceLayout: TerminalLayoutSnapshot + detachedLayout: TerminalLayoutSnapshot + ptyId: string | null +} + +function removeLeafFromTree( + node: TerminalPaneLayoutNode, + leafId: string +): { node: TerminalPaneLayoutNode | null; removed: boolean } { + if (node.type === 'leaf') { + return node.leafId === leafId ? { node: null, removed: true } : { node, removed: false } + } + + const first = removeLeafFromTree(node.first, leafId) + const second = removeLeafFromTree(node.second, leafId) + if (!first.removed && !second.removed) { + return { node, removed: false } + } + if (!first.node) { + return { node: second.node, removed: true } + } + if (!second.node) { + return { node: first.node, removed: true } + } + return { + node: { + ...node, + first: first.node, + second: second.node + }, + removed: true + } +} + +function omitLeafRecord( + source: Record | undefined, + leafId: string +): Record | undefined { + if (!source || !Object.prototype.hasOwnProperty.call(source, leafId)) { + return source + } + const next = { ...source } + delete next[leafId] + return Object.keys(next).length > 0 ? next : undefined +} + +function singleLeafRecord( + source: Record | undefined, + leafId: string +): Record | undefined { + const value = source?.[leafId] + return value ? { [leafId]: value } : undefined +} + +export function detachTerminalLayoutLeaf( + snapshot: TerminalLayoutSnapshot | null | undefined, + leafId: string +): DetachedTerminalLayoutLeaf | null { + const layout = normalizeTerminalLayoutSnapshot(snapshot).snapshot + if (!layout.root) { + return null + } + + const originalLeafIds = collectLeafIdsInOrder(layout.root) + if (!originalLeafIds.includes(leafId) || originalLeafIds.length <= 1) { + return null + } + + const removal = removeLeafFromTree(layout.root, leafId) + if (!removal.removed || !removal.node) { + return null + } + + const ptyIdsByLeafId = omitLeafRecord(layout.ptyIdsByLeafId, leafId) + const buffersByLeafId = omitLeafRecord(layout.buffersByLeafId, leafId) + const scrollbackRefsByLeafId = omitLeafRecord(layout.scrollbackRefsByLeafId, leafId) + const titlesByLeafId = omitLeafRecord(layout.titlesByLeafId, leafId) + const sourceLayout: TerminalLayoutSnapshot = { + root: removal.node, + activeLeafId: resolveTerminalLayoutActiveLeafId({ + root: removal.node, + activeLeafId: layout.activeLeafId === leafId ? null : layout.activeLeafId, + ptyIdsByLeafId + }), + expandedLeafId: layout.expandedLeafId === leafId ? null : layout.expandedLeafId, + ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), + ...(buffersByLeafId ? { buffersByLeafId } : {}), + ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), + ...(titlesByLeafId ? { titlesByLeafId } : {}) + } + + const detachedPtyIdsByLeafId = singleLeafRecord(layout.ptyIdsByLeafId, leafId) + const detachedBuffersByLeafId = singleLeafRecord(layout.buffersByLeafId, leafId) + const detachedScrollbackRefsByLeafId = singleLeafRecord(layout.scrollbackRefsByLeafId, leafId) + const detachedTitlesByLeafId = singleLeafRecord(layout.titlesByLeafId, leafId) + return { + sourceLayout, + detachedLayout: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ...(detachedPtyIdsByLeafId ? { ptyIdsByLeafId: detachedPtyIdsByLeafId } : {}), + ...(detachedBuffersByLeafId ? { buffersByLeafId: detachedBuffersByLeafId } : {}), + ...(detachedScrollbackRefsByLeafId + ? { scrollbackRefsByLeafId: detachedScrollbackRefsByLeafId } + : {}), + ...(detachedTitlesByLeafId ? { titlesByLeafId: detachedTitlesByLeafId } : {}) + }, + ptyId: detachedPtyIdsByLeafId?.[leafId] ?? null + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts new file mode 100644 index 00000000000..a92e03cce8c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts @@ -0,0 +1,383 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import { + detachTerminalPaneToTab, + resolveTerminalTabStripDropTarget, + type TerminalPaneTabDetachStore +} from './terminal-pane-tab-detach' + +const WORKTREE_ID = 'repo-1::/worktree' +const SOURCE_TAB_ID = 'tab-source' +const TARGET_GROUP_ID = 'group-target' +const EXISTING_TAB_1 = 'tab-existing-1' +const EXISTING_TAB_2 = 'tab-existing-2' +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' + +function rect(args: { left: number; top: number; width: number; height: number }): DOMRect { + return { + left: args.left, + top: args.top, + right: args.left + args.width, + bottom: args.top + args.height, + width: args.width, + height: args.height + } as DOMRect +} + +function splitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-left', + [LEAF_2]: 'remote:env-1@@terminal-1' + }, + buffersByLeafId: { + [LEAF_2]: 'remote-buffer' + }, + titlesByLeafId: { + [LEAF_2]: 'remote shell' + } + } +} + +function createTerminalTab(id: string, ptyId: string | null): TerminalTab { + return { + id, + ptyId, + worktreeId: WORKTREE_ID, + title: 'Terminal 2', + defaultTitle: 'Terminal 2', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 1 + } +} + +function createStore( + layout: TerminalLayoutSnapshot = splitLayout(), + targetTabOrder: string[] = [EXISTING_TAB_1, EXISTING_TAB_2] +): TerminalPaneTabDetachStore { + const store = { + createTab: vi.fn((_worktreeId, _targetGroupId, _shellOverride, options) => { + const tab = createTerminalTab('tab-detached', options?.initialPtyId ?? null) + const group = store.groupsByWorktree[WORKTREE_ID]?.find( + (candidate) => candidate.id === TARGET_GROUP_ID + ) + if (group && !group.tabOrder.includes(tab.id)) { + group.tabOrder = [...group.tabOrder, tab.id] + } + return tab + }), + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: targetTabOrder[0] ?? null, + tabOrder: targetTabOrder, + recentTabIds: [] + } + ] + }, + reorderUnifiedTabs: vi.fn((groupId: string, tabIds: string[]) => { + const group = store.groupsByWorktree[WORKTREE_ID]?.find( + (candidate) => candidate.id === groupId + ) + if (group) { + group.tabOrder = tabIds + } + }), + setActiveTab: vi.fn(), + setActiveTabType: vi.fn(), + setTabLayout: vi.fn((tabId: string, nextLayout: TerminalLayoutSnapshot | null) => { + if (nextLayout) { + store.terminalLayoutsByTabId[tabId] = nextLayout + } else { + delete store.terminalLayoutsByTabId[tabId] + } + }), + syncPaneDetachPtyOwnership: vi.fn(), + terminalLayoutsByTabId: { + [SOURCE_TAB_ID]: layout + } + } + return store as unknown as TerminalPaneTabDetachStore +} + +describe('resolveTerminalTabStripDropTarget', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('finds a same-worktree tab strip under overlay elements', () => { + const stripRect = rect({ left: 0, top: 0, width: 300, height: 32 }) + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: WORKTREE_ID }, + getBoundingClientRect: () => stripRect, + querySelectorAll: () => [] + } + const overlay = { closest: () => null } + const child = { closest: () => strip } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [overlay, child]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 10, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [{ id: TARGET_GROUP_ID } as AppState['groupsByWorktree'][string][number]] + }, + worktreeId: WORKTREE_ID + }) + ).toEqual({ + id: TARGET_GROUP_ID, + groupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID, + rect: stripRect + }) + }) + + it('resolves the insertion slot from the hovered tab side', () => { + const stripRect = rect({ left: 0, top: 0, width: 300, height: 32 }) + const firstTabRect = rect({ left: 0, top: 0, width: 80, height: 32 }) + const secondTabRect = rect({ left: 80, top: 0, width: 80, height: 32 }) + const firstTab = { + dataset: { tabId: EXISTING_TAB_1 }, + getBoundingClientRect: () => firstTabRect + } + const secondTab = { + dataset: { tabId: EXISTING_TAB_2 }, + getBoundingClientRect: () => secondTabRect + } + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: WORKTREE_ID }, + getBoundingClientRect: () => stripRect, + querySelectorAll: () => [firstTab, secondTab] + } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [{ closest: () => firstTab }, { closest: () => strip }]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 60, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: TARGET_GROUP_ID, + activeTabId: EXISTING_TAB_1, + tabOrder: [EXISTING_TAB_1, EXISTING_TAB_2], + worktreeId: WORKTREE_ID + } as AppState['groupsByWorktree'][string][number] + ] + }, + worktreeId: WORKTREE_ID + }) + ).toMatchObject({ + groupId: TARGET_GROUP_ID, + insertionIndex: 1, + overlayKind: 'insertion', + rect: rect({ left: 80, top: 0, width: 2, height: 32 }) + }) + }) + + it('ignores strips from another worktree', () => { + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: 'other-worktree' }, + getBoundingClientRect: () => + ({ left: 0, top: 0, right: 300, bottom: 32, width: 300, height: 32 }) as DOMRect + } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [{ closest: () => strip }]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 10, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [{ id: TARGET_GROUP_ID } as AppState['groupsByWorktree'][string][number]] + }, + worktreeId: WORKTREE_ID + }) + ).toBeNull() + }) +}) + +describe('detachTerminalPaneToTab', () => { + it('creates a new terminal tab with the detached leaf layout and PTY id', () => { + const store = createStore() + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1)), + detachPaneForExternalMove: vi.fn(() => true) + } + const persistLayoutSnapshot = vi.fn() + + const result = detachTerminalPaneToTab({ + manager, + getStore: () => store, + persistLayoutSnapshot, + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(result?.ptyId).toBe('remote:env-1@@terminal-1') + expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(2) + expect(store.createTab).toHaveBeenCalledWith(WORKTREE_ID, TARGET_GROUP_ID, undefined, { + activate: true, + initialPtyId: 'remote:env-1@@terminal-1', + recordInteraction: true + }) + expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-left' } + }) + expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({ + detachedPtyId: 'remote:env-1@@terminal-1', + sourceLayout: { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-left' } + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: 'tab-detached' + }) + expect(store.setActiveTab).toHaveBeenCalledWith('tab-detached') + expect(store.setActiveTabType).toHaveBeenCalledWith('terminal') + expect(persistLayoutSnapshot).toHaveBeenCalled() + }) + + it('syncs PTY ownership when the primary source pane is detached', () => { + const store = createStore() + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 1 ? LEAF_1 : LEAF_2)), + detachPaneForExternalMove: vi.fn(() => true) + } + + const result = detachTerminalPaneToTab({ + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 1, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(result?.ptyId).toBe('pty-left') + expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({ + detachedPtyId: 'pty-left', + sourceLayout: { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: 'tab-detached' + }) + }) + + it('moves the detached tab into the requested group slot', () => { + const store = createStore(splitLayout(), [EXISTING_TAB_1, EXISTING_TAB_2]) + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1)), + detachPaneForExternalMove: vi.fn(() => true) + } + + detachTerminalPaneToTab({ + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + targetIndex: 1, + worktreeId: WORKTREE_ID + }) + + expect(store.reorderUnifiedTabs).toHaveBeenCalledWith( + TARGET_GROUP_ID, + [EXISTING_TAB_1, 'tab-detached', EXISTING_TAB_2], + { recordInteraction: false } + ) + }) + + it('uses the live transport PTY id when the snapshot has not persisted it yet', () => { + const store = createStore({ + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_2, + expandedLeafId: null + }) + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn(() => LEAF_2), + detachPaneForExternalMove: vi.fn(() => true) + } + + detachTerminalPaneToTab({ + fallbackPtyId: 'remote:env-2@@terminal-9', + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' } + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts new file mode 100644 index 00000000000..26f3d5996e3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -0,0 +1,306 @@ +import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager' +import type { AppState } from '@/store' +import type { TerminalTab } from '../../../../shared/types' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' + +const TAB_GROUP_STRIP_SELECTOR = '[data-tab-group-strip-id][data-worktree-id]' + +export type TerminalPaneTabDetachStore = Pick< + AppState, + | 'createTab' + | 'groupsByWorktree' + | 'reorderUnifiedTabs' + | 'setActiveTab' + | 'setActiveTabType' + | 'setTabLayout' + | 'syncPaneDetachPtyOwnership' + | 'terminalLayoutsByTabId' +> + +type TerminalPaneTabDetachManager = { + getPanes: () => readonly { id: number }[] + getLeafId: (paneId: number) => string | null + detachPaneForExternalMove: (paneId: number) => boolean +} + +export type TerminalTabStripDropTarget = PaneExternalDropTarget & { + groupId: string + insertionIndex?: number + worktreeId: string +} + +export type DetachedTerminalPaneTab = { + tab: TerminalTab + leafId: string + ptyId: string | null +} + +function pointWithinRect(clientX: number, clientY: number, rect: DOMRect): boolean { + return ( + clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom + ) +} + +function rectFromBox(args: { left: number; top: number; width: number; height: number }): DOMRect { + return { + left: args.left, + top: args.top, + right: args.left + args.width, + bottom: args.top + args.height, + width: args.width, + height: args.height + } as DOMRect +} + +function clampIndex(index: number, max: number): number { + return Math.min(Math.max(index, 0), max) +} + +function getTabElements(strip: HTMLElement): HTMLElement[] { + return Array.from(strip.querySelectorAll('[data-tab-id]')).filter( + (element) => typeof element.dataset.tabId === 'string' && element.dataset.tabId.length > 0 + ) +} + +function getInsertionMarkerRect( + tabRects: DOMRect[], + insertionIndex: number, + stripRect: DOMRect +): DOMRect { + const markerWidth = 2 + const clampedIndex = clampIndex(insertionIndex, tabRects.length) + const rawLeft = + clampedIndex < tabRects.length + ? (tabRects[clampedIndex]?.left ?? stripRect.left) + : (tabRects.at(-1)?.right ?? stripRect.left) - markerWidth + const left = Math.min(Math.max(rawLeft, stripRect.left), stripRect.right - markerWidth) + return rectFromBox({ left, top: stripRect.top, width: markerWidth, height: stripRect.height }) +} + +function resolveTabStripInsertion(args: { + clientX: number + clientY: number + groupTabOrderLength: number + strip: HTMLElement + stripRect: DOMRect +}): { index: number; rect: DOMRect } | null { + const tabs = getTabElements(args.strip) + if (tabs.length === 0) { + return null + } + const tabRects = tabs.map((tab) => tab.getBoundingClientRect()) + + for (let index = 0; index < tabRects.length; index += 1) { + const tabRect = tabRects[index] + if (!tabRect) { + continue + } + if (args.clientX < tabRect.left) { + const insertionIndex = clampIndex(index, args.groupTabOrderLength) + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } + } + if (pointWithinRect(args.clientX, args.clientY, tabRect)) { + const insertionIndex = clampIndex( + index + (args.clientX < tabRect.left + tabRect.width / 2 ? 0 : 1), + args.groupTabOrderLength + ) + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } + } + } + + const insertionIndex = args.groupTabOrderLength + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } +} + +function getElementsFromPoint(clientX: number, clientY: number): Element[] { + if (typeof document === 'undefined') { + return [] + } + const elements = document.elementsFromPoint?.(clientX, clientY) + if (elements && elements.length > 0) { + return elements + } + const element = document.elementFromPoint?.(clientX, clientY) + return element ? [element] : [] +} + +export function resolveTerminalTabStripDropTarget(args: { + clientX: number + clientY: number + groupsByWorktree: TerminalPaneTabDetachStore['groupsByWorktree'] + worktreeId: string +}): TerminalTabStripDropTarget | null { + const groups = args.groupsByWorktree[args.worktreeId] ?? [] + const groupById = new Map(groups.map((group) => [group.id, group])) + const validGroupIds = new Set(groups.map((group) => group.id)) + if (validGroupIds.size === 0) { + return null + } + + for (const element of getElementsFromPoint(args.clientX, args.clientY)) { + const strip = element.closest(TAB_GROUP_STRIP_SELECTOR) + const groupId = strip?.dataset.tabGroupStripId + const worktreeId = strip?.dataset.worktreeId + if (!strip || !groupId || worktreeId !== args.worktreeId || !validGroupIds.has(groupId)) { + continue + } + const rect = strip.getBoundingClientRect() + if (!pointWithinRect(args.clientX, args.clientY, rect)) { + continue + } + const group = groupById.get(groupId) + const insertion = group + ? resolveTabStripInsertion({ + clientX: args.clientX, + clientY: args.clientY, + groupTabOrderLength: group.tabOrder?.length ?? 0, + strip, + stripRect: rect + }) + : null + return insertion + ? { + id: groupId, + groupId, + insertionIndex: insertion.index, + overlayKind: 'insertion', + rect: insertion.rect, + worktreeId + } + : { id: groupId, groupId, worktreeId, rect } + } + + return null +} + +function withDetachedPtyFallback(args: { + leafId: string + ptyId: string | null + detachedLayout: NonNullable>['detachedLayout'] +}): NonNullable>['detachedLayout'] { + if (!args.ptyId || args.detachedLayout.ptyIdsByLeafId?.[args.leafId]) { + return args.detachedLayout + } + return { + ...args.detachedLayout, + ptyIdsByLeafId: { + ...args.detachedLayout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } +} + +export function isTerminalTabStripDropTarget( + target: PaneExternalDropTarget +): target is TerminalTabStripDropTarget { + const candidate = target as Partial + return typeof candidate.groupId === 'string' && typeof candidate.worktreeId === 'string' +} + +function moveCreatedTabToIndex(args: { + groupId: string + store: TerminalPaneTabDetachStore + tabId: string + targetIndex: number | undefined + worktreeId: string +}): void { + if (args.targetIndex === undefined) { + return + } + const group = args.store.groupsByWorktree[args.worktreeId]?.find( + (candidate) => candidate.id === args.groupId + ) + if (!group) { + return + } + const orderWithoutCreatedTab = (group.tabOrder ?? []).filter((id) => id !== args.tabId) + const insertionIndex = clampIndex(args.targetIndex, orderWithoutCreatedTab.length) + const nextOrder = [...orderWithoutCreatedTab] + nextOrder.splice(insertionIndex, 0, args.tabId) + args.store.reorderUnifiedTabs(args.groupId, nextOrder, { recordInteraction: false }) +} + +export function detachTerminalPaneToTab(args: { + fallbackPtyId?: string | null + getStore: () => TerminalPaneTabDetachStore + manager: TerminalPaneTabDetachManager | null + persistLayoutSnapshot: () => void + sourcePaneId: number + sourceTabId: string + targetGroupId: string + targetIndex?: number + worktreeId: string +}): DetachedTerminalPaneTab | null { + const initialStore = args.getStore() + const targetGroupExists = + initialStore.groupsByWorktree[args.worktreeId]?.some( + (group) => group.id === args.targetGroupId + ) ?? false + if (!args.manager || !targetGroupExists || args.manager.getPanes().length <= 1) { + return null + } + + const sourceLeafId = args.manager.getLeafId(args.sourcePaneId) + if (!sourceLeafId) { + return null + } + + args.persistLayoutSnapshot() + const store = args.getStore() + const detached = detachTerminalLayoutLeaf( + store.terminalLayoutsByTabId[args.sourceTabId], + sourceLeafId + ) + if (!detached) { + return null + } + + const ptyId = detached.ptyId ?? args.fallbackPtyId ?? null + const detachedLayout = withDetachedPtyFallback({ + leafId: sourceLeafId, + ptyId, + detachedLayout: detached.detachedLayout + }) + + // Why: remove the renderer pane only after the layout/PTY handoff has been + // computed; the close callback detaches listeners but must not kill the PTY. + if (!args.manager.detachPaneForExternalMove(args.sourcePaneId)) { + return null + } + + const latestStore = args.getStore() + const tab = latestStore.createTab(args.worktreeId, args.targetGroupId, undefined, { + activate: true, + initialPtyId: ptyId ?? undefined, + recordInteraction: true + }) + const afterCreateStore = args.getStore() + moveCreatedTabToIndex({ + groupId: args.targetGroupId, + store: afterCreateStore, + tabId: tab.id, + targetIndex: args.targetIndex, + worktreeId: args.worktreeId + }) + afterCreateStore.setTabLayout(args.sourceTabId, detached.sourceLayout) + afterCreateStore.setTabLayout(tab.id, detachedLayout) + afterCreateStore.syncPaneDetachPtyOwnership({ + detachedPtyId: ptyId, + sourceLayout: detached.sourceLayout, + sourceTabId: args.sourceTabId, + targetTabId: tab.id + }) + afterCreateStore.setActiveTab(tab.id) + afterCreateStore.setActiveTabType('terminal') + + return { tab, leafId: sourceLeafId, ptyId } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 1674e150fcd..3597f9dd28e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -2,7 +2,11 @@ import { useEffect, useRef } from 'react' import type { IDisposable, Terminal } from '@xterm/xterm' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' -import { PaneManager } from '@/lib/pane-manager/pane-manager' +import { + PaneManager, + type PaneExternalDropHandler, + type PaneExternalDropResolver +} from '@/lib/pane-manager/pane-manager' import { consumePendingWebRuntimeSplitMirrorTelemetry } from '@/runtime/web-runtime-session' import { normalizeTerminalFastScrollSensitivity, @@ -273,6 +277,8 @@ type UseTerminalPaneLifecycleDeps = { // Why: same pane count does not imply same geometry; drag-reorder can move // panes without resizing them, so overlay rects need a layout-change tick. setPaneLayoutRevision: React.Dispatch> + resolveExternalPaneDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler } export function suppressIntentionalPaneCloseExit( @@ -532,7 +538,9 @@ export function useTerminalPaneLifecycle({ paneTitlesRef, setRenamingPaneId, setPaneCount, - setPaneLayoutRevision + setPaneLayoutRevision, + resolveExternalPaneDropTarget, + onExternalPaneDrop }: UseTerminalPaneLifecycleDeps): void { const terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( settings?.terminalScrollbackRows @@ -1094,6 +1102,7 @@ export function useTerminalPaneLifecycle({ queueResizeAll(true) }, onPaneClosed: (paneId, closedPane) => { + const isDetachedToTab = closedPane?.reason === 'detach' const linkProviderDisposable = linkProviderDisposablesRef.current.get(paneId) if (linkProviderDisposable) { linkProviderDisposable.dispose() @@ -1169,31 +1178,37 @@ export function useTerminalPaneLifecycle({ panePtyBinding.dispose() panePtyBindings.delete(paneId) } - // Why: closing a pane is user-initiated teardown of this row — drop - // (not remove) so any retained `done` snapshot for this pane is also - // cleared and a same-frame live→gone transition cannot re-snapshot - // it via the retention sync. This is pane-keyed state, so it must - // clear even if the PTY transport was already removed. const leafId = closedPane?.leafId - if (leafId) { + if (leafId && !isDetachedToTab) { + // Why: closing a pane is user-initiated teardown of this row — drop + // (not remove) so any retained `done` snapshot for this pane is also + // cleared and a same-frame live→gone transition cannot re-snapshot + // it via the retention sync. This is pane-keyed state, so it must + // clear even if the PTY transport was already removed. const paneKey = makePaneKey(tabId, leafId) useAppStore.getState().setCacheTimerStartedAt(paneKey, null) clearTerminalPaneUnread(paneKey) useAppStore.getState().dropAgentStatus(paneKey) } if (transport) { - const ptyId = suppressIntentionalPaneCloseExit( - transport, - useAppStore.getState().suppressPtyExit - ) - if (ptyId) { - // Why: user/CLI pane closes intentionally tear down this PTY after - // PaneManager has already promoted the sibling. Suppress that exit - // so the last-surviving pane is not mistaken for an exited tab. - syncPanePtyLayoutBinding(paneId, null) - clearTabPtyId(tabId, ptyId) + if (isDetachedToTab) { + // Why: pane-to-tab detach hands the PTY to a newly-created tab; + // detach renderer listeners without sending a process teardown. + transport.detach?.() + } else { + const ptyId = suppressIntentionalPaneCloseExit( + transport, + useAppStore.getState().suppressPtyExit + ) + if (ptyId) { + // Why: user/CLI pane closes intentionally tear down this PTY after + // PaneManager has already promoted the sibling. Suppress that exit + // so the last-surviving pane is not mistaken for an exited tab. + syncPanePtyLayoutBinding(paneId, null) + clearTabPtyId(tabId, ptyId) + } + transport.destroy?.() } - transport.destroy?.() paneTransportsRef.current.delete(paneId) } clearRuntimePaneTitle(tabId, paneId) @@ -1292,6 +1307,8 @@ export function useTerminalPaneLifecycle({ releaseWebviewDragPassthrough?.() releaseWebviewDragPassthrough = null }, + resolveExternalPaneDropTarget, + onExternalPaneDrop, terminalOptions: () => { const currentSettings = settingsRef.current const terminalFontWeights = resolveTerminalFontWeights(currentSettings?.terminalFontWeight) diff --git a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts index 45d71f151e0..3be4db71d72 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts @@ -1,4 +1,4 @@ -import type { DropZone, ManagedPaneInternal } from './pane-manager-types' +import type { DropZone, ManagedPaneInternal, PaneExternalDropTarget } from './pane-manager-types' import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder' import { handlePaneDrop, @@ -62,14 +62,18 @@ export function beginPaneDragFromPointerDown( callbacks.getRoot().classList.remove('is-pane-dragging') callbacks.getPanes().get(paneId)?.container.classList.remove('is-drag-source') try { - if (commitDrop && state.currentDropTarget && state.dragSourcePaneId !== null) { - handlePaneDrop( - state.dragSourcePaneId, - state.currentDropTarget.paneId, - state.currentDropTarget.zone, - state, - callbacks - ) + if (commitDrop && state.dragSourcePaneId !== null) { + if (state.currentDropTarget) { + handlePaneDrop( + state.dragSourcePaneId, + state.currentDropTarget.paneId, + state.currentDropTarget.zone, + state, + callbacks + ) + } else if (state.currentExternalDropTarget) { + callbacks.onExternalPaneDrop?.(state.dragSourcePaneId, state.currentExternalDropTarget) + } } } finally { // Why: pointer capture can be lost crossing Electron webviews; always @@ -78,6 +82,7 @@ export function beginPaneDragFromPointerDown( hideDropOverlay(state) state.dragSourcePaneId = null state.currentDropTarget = null + state.currentExternalDropTarget = null } } @@ -166,8 +171,20 @@ function updateDropTarget( } const targetPane = findDropTargetPane(clientX, clientY, state, callbacks) if (!targetPane) { - overlay.style.display = 'none' + const sourcePaneId = state.dragSourcePaneId + const externalTarget = + sourcePaneId === null + ? null + : (callbacks.resolveExternalDropTarget?.({ sourcePaneId, clientX, clientY }) ?? null) + if (!externalTarget) { + overlay.style.display = 'none' + state.currentDropTarget = null + state.currentExternalDropTarget = null + return + } state.currentDropTarget = null + state.currentExternalDropTarget = externalTarget + positionExternalDropOverlay(overlay, externalTarget) return } @@ -180,9 +197,11 @@ function updateDropTarget( ) { overlay.style.display = 'none' state.currentDropTarget = null + state.currentExternalDropTarget = null return } state.currentDropTarget = { paneId: targetPane.id, zone } + state.currentExternalDropTarget = null positionDropOverlay(overlay, rect, zone) } @@ -223,6 +242,7 @@ function resolveDropZone(clientX: number, clientY: number, rect: DOMRect): DropZ function positionDropOverlay(overlay: HTMLElement, rect: DOMRect, zone: DropZone): void { overlay.style.display = '' + overlay.dataset.paneDropOverlayKind = 'area' const scrollX = window.scrollX const scrollY = window.scrollY const halfWidth = rect.width / 2 @@ -233,3 +253,13 @@ function positionDropOverlay(overlay: HTMLElement, rect: DOMRect, zone: DropZone overlay.style.width = `${zone === 'left' || zone === 'right' ? halfWidth : rect.width}px` overlay.style.height = `${zone === 'top' || zone === 'bottom' ? halfHeight : rect.height}px` } + +function positionExternalDropOverlay(overlay: HTMLElement, target: PaneExternalDropTarget): void { + const rect = target.rect + overlay.style.display = '' + overlay.dataset.paneDropOverlayKind = target.overlayKind ?? 'area' + overlay.style.left = `${rect.left + window.scrollX}px` + overlay.style.top = `${rect.top + window.scrollY}px` + overlay.style.width = `${rect.width}px` + overlay.style.height = `${rect.height}px` +} diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts index dd81b2063fe..1f5996526cd 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts @@ -229,6 +229,7 @@ describe('attachPaneDrag', () => { expect(appendedElements[0].removed).toBe(true) expect(state.dragSourcePaneId).toBeNull() expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBeNull() expect(state.cleanupActiveDrag).toBeNull() expect(onDragActiveChange).toHaveBeenLastCalledWith(false) expect(detachPaneFromTree).not.toHaveBeenCalled() @@ -288,10 +289,83 @@ describe('attachPaneDrag', () => { expect(appendedElements[0]?.removed).toBe(true) expect(state.dragSourcePaneId).toBeNull() expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBeNull() expect(state.cleanupActiveDrag).toBeNull() expect(onDragActiveChange).toHaveBeenLastCalledWith(false) }) + it('drops onto an external target when no pane target is under the pointer', () => { + const handle = new FakeElement() + const root = new FakeElement(['pane-manager-root']) + const sourcePane = createPane( + 1, + new FakeElement(['pane'], { + left: 0, + top: 80, + right: 100, + bottom: 180, + width: 100, + height: 100 + }) + ) + const siblingPane = createPane( + 2, + new FakeElement(['pane'], { + left: 120, + top: 80, + right: 220, + bottom: 180, + width: 100, + height: 100 + }) + ) + const panes = new Map([ + [sourcePane.id, sourcePane], + [siblingPane.id, siblingPane] + ]) + const externalTarget = { + id: 'group-1', + overlayKind: 'insertion' as const, + rect: { left: 0, top: 0, right: 300, bottom: 32, width: 300, height: 32 } as DOMRect + } + const onExternalPaneDrop = vi.fn(() => true) + const state = createDragReorderState() + + attachPaneDrag(handle as unknown as HTMLElement, sourcePane.id, state, { + getPanes: () => panes, + getRoot: () => root as unknown as HTMLElement, + getStyleOptions: () => ({}), + isDestroyed: () => false, + safeFit: vi.fn(), + applyPaneOpacity: vi.fn(), + applyDividerStyles: vi.fn(), + refitPanesUnder: vi.fn(), + resolveExternalDropTarget: ({ clientX, clientY }) => + clientX === 10 && clientY === 10 ? externalTarget : null, + onExternalPaneDrop + }) + + handle.dispatchPointer('pointerdown', pointerEvent({ clientX: 10, clientY: 90 })) + handle.dispatchPointer('pointermove', pointerEvent({ clientX: 10, clientY: 10 })) + + expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBe(externalTarget) + expect(appendedElements[0].style).toMatchObject({ + left: '0px', + top: '0px', + width: '300px', + height: '32px' + }) + expect(appendedElements[0].dataset.paneDropOverlayKind).toBe('insertion') + + handle.dispatchPointer('pointerup', pointerEvent({ pointerId: 1 })) + + expect(onExternalPaneDrop).toHaveBeenCalledWith(sourcePane.id, externalTarget) + expect(detachPaneFromTree).not.toHaveBeenCalled() + expect(insertPaneNextTo).not.toHaveBeenCalled() + expect(state.currentExternalDropTarget).toBeNull() + }) + it('returns cleanup that removes handle listeners and cancels active drag capture', () => { const handle = new FakeElement() const root = new FakeElement(['pane-manager-root']) diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts index 6b08dc4b48e..7c1803227c1 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts @@ -1,5 +1,12 @@ -import type { DropZone, ManagedPane, ManagedPaneInternal } from './pane-manager-types' -import type { PaneStyleOptions } from './pane-manager-types' +import type { + DropZone, + ManagedPane, + ManagedPaneInternal, + PaneExternalDropHandler, + PaneExternalDropResolver, + PaneExternalDropTarget, + PaneStyleOptions +} from './pane-manager-types' import { detachPaneFromTree, findPaneChildren, insertPaneNextTo } from './pane-tree-ops' // --------------------------------------------------------------------------- @@ -10,6 +17,7 @@ export type DragReorderState = { dragSourcePaneId: number | null dropOverlay: HTMLElement | null currentDropTarget: { paneId: number; zone: DropZone } | null + currentExternalDropTarget: PaneExternalDropTarget | null cleanupActiveDrag: ((commitDrop: boolean) => void) | null } @@ -25,6 +33,8 @@ export type DragReorderCallbacks = { requestPaneReparentFrame?: (callback: FrameRequestCallback) => void onLayoutChanged?: () => void onDragActiveChange?: (active: boolean) => void + resolveExternalDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler } export function createDragReorderState(): DragReorderState { @@ -32,6 +42,7 @@ export function createDragReorderState(): DragReorderState { dragSourcePaneId: null, dropOverlay: null, currentDropTarget: null, + currentExternalDropTarget: null, cleanupActiveDrag: null } } @@ -44,6 +55,7 @@ export function cancelActivePaneDrag(state: DragReorderState): void { hideDropOverlay(state) state.dragSourcePaneId = null state.currentDropTarget = null + state.currentExternalDropTarget = null } /** True when dropping source onto target in zone would leave pane order unchanged. */ diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index e4e002a5351..1ab542bf787 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -27,8 +27,26 @@ export type PaneSpawnHints = { export type ClosedPaneInfo = { paneId: number leafId: TerminalLeafId + reason?: 'close' | 'detach' } +export type PaneExternalDropTarget = { + id: string + rect: DOMRect + overlayKind?: 'area' | 'insertion' +} + +export type PaneExternalDropResolver = (args: { + sourcePaneId: number + clientX: number + clientY: number +}) => PaneExternalDropTarget | null + +export type PaneExternalDropHandler = ( + sourcePaneId: number, + target: PaneExternalDropTarget +) => boolean + export type PaneManagerOptions = { onPaneCreated?: (pane: ManagedPane, spawnHints?: PaneSpawnHints) => void | Promise onPaneClosed?: (paneId: number, closedPane?: ClosedPaneInfo) => void @@ -37,6 +55,8 @@ export type PaneManagerOptions = { /** Why: Electron webviews can steal pointer streams from renderer-owned * pane drags unless callers temporarily put them in pointer passthrough. */ onPaneDragActiveChange?: (active: boolean) => void + resolveExternalPaneDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler terminalOptions?: (paneId: number) => Partial terminalTuiScrollSensitivity?: () => number | undefined onLinkClick?: (event: MouseEvent | undefined, url: string) => void diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 61ff5a7d42d..77ad5398890 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -5,7 +5,10 @@ import type { ManagedPane, ManagedPaneInternal, PaneRenderingDiagnostics, - DropZone + DropZone, + PaneExternalDropHandler, + PaneExternalDropResolver, + PaneExternalDropTarget } from './pane-manager-types' import type { SplitPaneAroundLeafIdsOptions } from './pane-subtree-split' import { @@ -40,11 +43,23 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry' import { schedulePaneRevealRepaint } from './pane-reveal-repaint' import { PaneIdentityRegistry } from './pane-identity-registry' -import { closeManagedPane, splitManagedPane } from './pane-split-close' +import { + closeManagedPane, + detachManagedPaneForExternalMove, + splitManagedPane +} from './pane-split-close' import { FIRST_PANE_ID } from '../../../../shared/pane-key' import { splitPaneAroundMountedSubtree } from './pane-subtree-split' -export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone } +export type { + PaneManagerOptions, + PaneStyleOptions, + ManagedPane, + DropZone, + PaneExternalDropTarget, + PaneExternalDropResolver, + PaneExternalDropHandler +} export class PaneManager { private root: HTMLElement @@ -158,6 +173,22 @@ export class PaneManager { }) } + detachPaneForExternalMove(paneId: number): boolean { + return detachManagedPaneForExternalMove({ + paneId, + activePaneId: this.activePaneId, + panes: this.panes, + root: this.root, + styleOptions: this.styleOptions, + managerOptions: this.options, + getDragCallbacks: () => this.getDragCallbacks(), + releasePaneIdentity: (numericPaneId) => this.identities.release(numericPaneId), + setActivePaneId: (id) => { + this.activePaneId = id + } + }) + } + getPanes(): ManagedPane[] { return Array.from(this.panes.values()).map(toPublicPane) } @@ -404,7 +435,9 @@ export class PaneManager { this.requestPaneReparentFrame(callback) }, onLayoutChanged: this.options.onLayoutChanged, - onDragActiveChange: this.options.onPaneDragActiveChange + onDragActiveChange: this.options.onPaneDragActiveChange, + resolveExternalDropTarget: this.options.resolveExternalPaneDropTarget, + onExternalPaneDrop: this.options.onExternalPaneDrop } } diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 2d7c50a62f0..b7d8356e0a5 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -180,10 +180,39 @@ export function closeManagedPane(args: CloseManagedPaneArgs): void { safeFit(p) } updateMultiPaneState(args.getDragCallbacks()) - args.managerOptions.onPaneClosed?.(args.paneId, { paneId: args.paneId, leafId: closedLeafId }) + args.managerOptions.onPaneClosed?.(args.paneId, { + paneId: args.paneId, + leafId: closedLeafId, + reason: 'close' + }) args.managerOptions.onLayoutChanged?.() } +export function detachManagedPaneForExternalMove(args: CloseManagedPaneArgs): boolean { + const pane = args.panes.get(args.paneId) + if (!pane || args.panes.size <= 1) { + return false + } + const closedLeafId = pane.leafId + args.releasePaneIdentity(args.paneId) + removePaneContainer(args, pane) + const nextActivePaneId = activateReplacementPane(args) + applyPaneOpacity(args.panes.values(), nextActivePaneId, args.styleOptions) + for (const p of args.panes.values()) { + safeFit(p) + } + updateMultiPaneState(args.getDragCallbacks()) + // Why: pane-to-tab detach tears down only this renderer pane; the PTY is + // adopted by the new tab, so TerminalPane must skip process-close cleanup. + args.managerOptions.onPaneClosed?.(args.paneId, { + paneId: args.paneId, + leafId: closedLeafId, + reason: 'detach' + }) + args.managerOptions.onLayoutChanged?.() + return true +} + function removePaneContainer(args: CloseManagedPaneArgs, pane: ManagedPaneInternal): void { const paneContainer = pane.container const parent = paneContainer.parentElement diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 9e2f89bc160..fbecc991416 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -1117,6 +1117,57 @@ describe('setActiveWorktree', () => { expect(groups[0].tabOrder).toEqual([terminal.id]) }) + it('moves live PTY ownership when detaching a primary pane to a tab', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const sourceTabId = 'tab-source' + const targetTabId = 'tab-target' + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [ + makeTab({ id: sourceTabId, worktreeId: wt, ptyId: 'pty-detached' }), + makeTab({ id: targetTabId, worktreeId: wt, ptyId: null }) + ] + }, + ptyIdsByTabId: { + [sourceTabId]: ['pty-detached', 'pty-survivor'], + [targetTabId]: ['pty-detached'] + }, + lastKnownRelayPtyIdByTabId: { + [sourceTabId]: 'pty-detached', + [targetTabId]: 'pty-detached' + } + }) + + store.getState().syncPaneDetachPtyOwnership({ + detachedPtyId: 'pty-detached', + sourceLayout: { + root: { type: 'leaf', leafId: 'survivor-leaf' }, + activeLeafId: 'survivor-leaf', + expandedLeafId: null, + ptyIdsByLeafId: { 'survivor-leaf': 'pty-survivor' } + }, + sourceTabId, + targetTabId + }) + + const state = store.getState() + expect(state.ptyIdsByTabId[sourceTabId]).toEqual(['pty-survivor']) + expect(state.ptyIdsByTabId[targetTabId]).toEqual(['pty-detached']) + expect(state.lastKnownRelayPtyIdByTabId[sourceTabId]).toBe('pty-survivor') + expect(state.lastKnownRelayPtyIdByTabId[targetTabId]).toBe('pty-detached') + expect(state.tabsByWorktree[wt].find((tab) => tab.id === sourceTabId)?.ptyId).toBe( + 'pty-survivor' + ) + expect(state.tabsByWorktree[wt].find((tab) => tab.id === targetTabId)?.ptyId).toBe( + 'pty-detached' + ) + }) + it('stores trimmed quick command labels on terminal and unified tabs', () => { const store = createTestStore() const wt = 'repo1::/path/wt1' diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 0e6f30ddefc..bbd43ff773b 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -293,6 +293,36 @@ function equalStringSets(a: readonly string[], b: readonly string[]): boolean { return a.every((value) => bSet.has(value)) } +function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] { + return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))] +} + +function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null { + const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {} + const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined + return activePtyId ?? Object.values(ptyIdsByLeafId)[0] ?? null +} + +function withTerminalTabPtyId( + tabsByWorktree: Record, + tabId: string, + ptyId: string | null +): Record { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const index = tabs.findIndex((tab) => tab.id === tabId) + if (index === -1) { + continue + } + if (tabs[index]?.ptyId === ptyId) { + return tabsByWorktree + } + const nextTabs = [...tabs] + nextTabs[index] = { ...nextTabs[index]!, ptyId } + return { ...tabsByWorktree, [worktreeId]: nextTabs } + } + return tabsByWorktree +} + export type AutomaticAgentResumeClaim = { worktreeId: string launchAgent: TuiAgent @@ -506,6 +536,12 @@ export type TerminalSlice = { setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void + syncPaneDetachPtyOwnership: (args: { + detachedPtyId: string | null + sourceLayout: TerminalLayoutSnapshot + sourceTabId: string + targetTabId: string + }) => void queueTabStartupCommand: ( tabId: string, startup: { @@ -2455,6 +2491,54 @@ export const createTerminalSlice: StateCreator }) }, + syncPaneDetachPtyOwnership: ({ detachedPtyId, sourceLayout, sourceTabId, targetTabId }) => { + set((s) => { + const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {})) + const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter( + (ptyId) => ptyId !== detachedPtyId + ) + const sourcePtyIds = layoutSourcePtyIds.length > 0 ? layoutSourcePtyIds : existingSourcePtyIds + const sourcePrimaryPtyId = resolvePrimaryLayoutPtyId(sourceLayout) ?? sourcePtyIds[0] ?? null + const nextPtyIdsByTabId = { + ...s.ptyIdsByTabId, + [sourceTabId]: sourcePtyIds + } + if (detachedPtyId) { + nextPtyIdsByTabId[targetTabId] = uniquePtyIds([ + ...(nextPtyIdsByTabId[targetTabId] ?? []), + detachedPtyId + ]) + } + + const nextLastKnownRelayPtyIdByTabId = { ...s.lastKnownRelayPtyIdByTabId } + if (sourcePrimaryPtyId) { + nextLastKnownRelayPtyIdByTabId[sourceTabId] = sourcePrimaryPtyId + } else { + delete nextLastKnownRelayPtyIdByTabId[sourceTabId] + } + if (detachedPtyId) { + nextLastKnownRelayPtyIdByTabId[targetTabId] = detachedPtyId + } + + // Why: pane-to-tab detach moves live PTY ownership without spawning or + // exiting processes, so sync identity maps directly without activity bumps. + const sourceTabsByWorktree = withTerminalTabPtyId( + s.tabsByWorktree, + sourceTabId, + sourcePrimaryPtyId + ) + const nextTabsByWorktree = detachedPtyId + ? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId) + : sourceTabsByWorktree + + return { + ptyIdsByTabId: nextPtyIdsByTabId, + lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId, + ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}) + } + }) + }, + queueTabStartupCommand: (tabId, startup) => { // Why: launchToken is only meaningful for tracked launch-config reuse; // plain startup commands must not mint or carry a synthetic token. From ce680071a24a213608becb05736c1323853c3a90 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:15:24 -0700 Subject: [PATCH 65/70] Place terminal URL tooltip at pane corner (#7221) --- .../pane-manager/pane-dom-creation.test.ts | 16 ++++++ .../src/lib/pane-manager/pane-dom-creation.ts | 4 +- .../lib/pane-manager/pane-lifecycle.test.ts | 55 ++++++++++++++----- .../src/lib/pane-manager/pane-lifecycle.ts | 6 +- 4 files changed, 63 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts index 7f256d4304a..3165e86c04e 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts @@ -57,6 +57,22 @@ afterEach(() => { }) describe('createPaneDOM link tooltips', () => { + it('anchors WebLinks hover text to the unpadded terminal window corner', () => { + const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + const pane = createPaneDOM( + 1, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + + expect(pane.linkTooltip.style.left).toBe('0px') + expect(pane.linkTooltip.style.bottom).toBe('0px') + }) + it('uses desktop modifier-click text for WebLinks hover hints', () => { const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 2e556f44400..43b628ffbd5 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -66,8 +66,10 @@ export function createPaneDOM( const linkTooltip = document.createElement('div') linkTooltip.className = 'pane-link-tooltip' linkTooltip.classList.add('xterm-hover') + // Why: Ghostty-style URL hover belongs to the terminal window corner; do not + // let terminal content padding shift it inward. linkTooltip.style.cssText = - 'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' + + 'display:none;position:absolute;bottom:0;left:0;z-index:40;' + 'padding:5px 8px;border-radius:4px;font-size:11px;font-family:inherit;' + 'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' + 'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index cb41bca09b8..16791a485b4 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -444,14 +444,32 @@ describe('openTerminal — Unicode 11 ordering', () => { } } - const fakeContainer = { + const fakePaneContainer = { appendChild: vi.fn(), addEventListener: vi.fn() } as unknown as HTMLDivElement + const fakeXtermContainer = { + appendChild: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } as unknown as HTMLDivElement const fakeTooltip = {} as unknown as HTMLDivElement + const fakeTerminalElement = { + appendChild: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + querySelector: vi.fn(() => null), + classList: { contains: vi.fn(() => false) } + } as unknown as HTMLElement + vi.stubGlobal( + 'MutationObserver', + vi.fn(function MutationObserver() { + return { observe: vi.fn(), disconnect: vi.fn() } + }) + ) const terminal = { - element: null as HTMLElement | null, + element: fakeTerminalElement, textarea: null, cols: 80, rows: 24, @@ -485,8 +503,8 @@ describe('openTerminal — Unicode 11 ordering', () => { leafId, stablePaneId: leafId, terminal, - container: fakeContainer, - xtermContainer: fakeContainer, + container: fakePaneContainer, + xtermContainer: fakeXtermContainer, linkTooltip: fakeTooltip, terminalGpuAcceleration: 'off', gpuRenderingEnabled: false, @@ -507,19 +525,26 @@ describe('openTerminal — Unicode 11 ordering', () => { debugLabel: null } - openTerminal(pane) + try { + openTerminal(pane) - expect(events).toContain('loadAddon:unicode11') - expect(events).toContain('activeVersion=11') + expect(fakePaneContainer.appendChild).toHaveBeenCalledWith(fakeTooltip) + expect(fakeXtermContainer.appendChild).not.toHaveBeenCalled() + expect(fakeTerminalElement.appendChild).not.toHaveBeenCalled() + expect(events).toContain('loadAddon:unicode11') + expect(events).toContain('activeVersion=11') - const unicodeIdx = events.indexOf('activeVersion=11') - const writeIdx = events.indexOf('write') - if (writeIdx !== -1) { - expect(unicodeIdx).toBeLessThan(writeIdx) + const unicodeIdx = events.indexOf('activeVersion=11') + const writeIdx = events.indexOf('write') + if (writeIdx !== -1) { + expect(unicodeIdx).toBeLessThan(writeIdx) + } + + const loadUnicodeIdx = events.indexOf('loadAddon:unicode11') + expect(loadUnicodeIdx).toBeLessThan(unicodeIdx) + expect(events.indexOf('open')).toBeLessThan(loadUnicodeIdx) + } finally { + vi.unstubAllGlobals() } - - const loadUnicodeIdx = events.indexOf('loadAddon:unicode11') - expect(loadUnicodeIdx).toBeLessThan(unicodeIdx) - expect(events.indexOf('open')).toBeLessThan(loadUnicodeIdx) }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 490b09bb065..c6727c3c1c1 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -29,6 +29,7 @@ export { createPaneDOM } from './pane-dom-creation' export function openTerminal(pane: ManagedPaneInternal): void { const { terminal, + container, xtermContainer, linkTooltip, terminalTuiScrollSensitivity, @@ -41,8 +42,9 @@ export function openTerminal(pane: ManagedPaneInternal): void { // Open terminal into DOM terminal.open(xtermContainer) - const linkTooltipContainer = terminal.element ?? xtermContainer - linkTooltipContainer.appendChild(linkTooltip) + // Why: terminal.element sits under the padded xterm container. Pane-level + // placement keeps the hover URL on the true bottom-left window corner. + container.appendChild(linkTooltip) // Load addons (order matters: WebGL must be after open()) terminal.loadAddon(fitAddon) From 653f349b5f7bcbf049687e00794c612b8bf3f364 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:55:27 -0700 Subject: [PATCH 66/70] Fix terminal theme selection persistence (#7227) Fixes #7166. --- src/main/persistence.test.ts | 18 ++++ .../TerminalThemeSections.lifecycle.test.ts | 93 +++++++++++++++++-- .../settings/TerminalThemeSections.tsx | 59 +++++++++++- 3 files changed, 157 insertions(+), 13 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 419016f6f83..2e609b20558 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -2828,6 +2828,24 @@ describe('Store', () => { expect(store.getSettings().terminalUseSeparateLightTheme).toBe(false) }) + it('round-trips selected terminal theme names across reload', async () => { + const store = await createStore() + + store.updateSettings({ + terminalThemeDark: 'One Light', + terminalThemeLight: 'GitHub Light' + }) + store.flush() + + const persisted = readDataFile() as PersistedState + expect(persisted.settings.terminalThemeDark).toBe('One Light') + expect(persisted.settings.terminalThemeLight).toBe('GitHub Light') + + const reopened = await createStore() + expect(reopened.getSettings().terminalThemeDark).toBe('One Light') + expect(reopened.getSettings().terminalThemeLight).toBe('GitHub Light') + }) + // ── 5. addRepo and getRepo ────────────────────────────────────────── it('addRepo stores a repo retrievable by getRepo', async () => { diff --git a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts index 5e8b643e111..0114ea3ebf4 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts +++ b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts @@ -8,12 +8,15 @@ vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { ...actual, - useState: (initial: T) => [ - themeTarget ?? initial, - (value: T) => { - themeTarget = value as 'dark' | 'light' - } - ] + useState: (initial: T | (() => T)) => { + const initialValue = typeof initial === 'function' ? (initial as () => T)() : initial + return [ + themeTarget ?? initialValue, + (value: T) => { + themeTarget = value as 'dark' | 'light' + } + ] + } } }) @@ -23,7 +26,10 @@ vi.mock('./TerminalSettingsPreview', () => ({ } })) -import { TerminalThemeCatalogSection } from './TerminalThemeSections' +import { + resetTerminalThemeTargetMemoryForTests, + TerminalThemeCatalogSection +} from './TerminalThemeSections' type ReactElementLike = { type: unknown @@ -50,6 +56,7 @@ const warpThemesMock: UseWarpThemeImportReturn = { function makeSettings(overrides: Partial = {}): GlobalSettings { return { + theme: 'system', terminalUseSeparateLightTheme: false, terminalThemeDark: 'Ghostty Default Style Dark', terminalThemeLight: 'Builtin Tango Light', @@ -64,12 +71,13 @@ function renderCatalog( settings = makeSettings(), updateSettings = vi.fn(), target?: 'dark' | 'light', - preferredTarget?: 'dark' | 'light' + preferredTarget?: 'dark' | 'light', + systemPrefersDark = true ): React.JSX.Element { themeTarget = target return TerminalThemeCatalogSection({ settings, - systemPrefersDark: true, + systemPrefersDark, themeSearch: '', setThemeSearch: () => {}, updateSettings, @@ -167,6 +175,7 @@ function findButtonTexts(node: unknown): string[] { describe('TerminalThemeCatalogSection', () => { beforeEach(() => { themeTarget = 'dark' + resetTerminalThemeTargetMemoryForTests() vi.clearAllMocks() }) @@ -180,6 +189,72 @@ describe('TerminalThemeCatalogSection', () => { ) }) + it('opens on the active light theme when the system appearance is light', () => { + const updateSettings = vi.fn() + const element = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + updateSettings, + undefined, + undefined, + false + ) + const picker = findElementByTypeName(element, 'ThemePicker') + const preview = findElementByTypeName(element, 'TerminalSettingsPreview') + const selectTheme = picker?.props?.onSelectTheme as (theme: string) => void + + expect(picker?.props?.selectedTheme).toBe('Builtin Tango Light') + expect(preview?.props?.modeOverride).toBe('light') + + selectTheme('GitHub Light') + + expect(updateSettings).toHaveBeenCalledWith({ terminalThemeLight: 'GitHub Light' }) + }) + + it('reopens the manually edited light target while the active appearance is dark', () => { + const firstOpen = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + vi.fn(), + undefined, + undefined, + true + ) + const targetControl = findElementByTypeName(firstOpen, 'SettingsSegmentedControl') + const selectTarget = targetControl?.props?.onChange as (target: 'dark' | 'light') => void + + selectTarget('light') + + const reopened = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + vi.fn(), + undefined, + undefined, + true + ) + const picker = findElementByTypeName(reopened, 'ThemePicker') + const preview = findElementByTypeName(reopened, 'TerminalSettingsPreview') + + expect(picker?.props?.selectedTheme).toBe('Builtin Tango Light') + expect(preview?.props?.modeOverride).toBe('light') + }) + + it('opens a customized light-only theme after reload even when the active appearance is dark', () => { + const element = renderCatalog( + makeSettings({ + terminalUseSeparateLightTheme: true, + terminalThemeLight: 'GitHub Light' + }), + vi.fn(), + undefined, + undefined, + true + ) + const picker = findElementByTypeName(element, 'ThemePicker') + const preview = findElementByTypeName(element, 'TerminalSettingsPreview') + + expect(picker?.props?.selectedTheme).toBe('GitHub Light') + expect(preview?.props?.modeOverride).toBe('light') + }) + it('keeps the light target enabled while separate light theme is disabled', () => { const element = renderCatalog(makeSettings({ terminalUseSeparateLightTheme: false }), vi.fn()) const targetControl = findElementByTypeName(element, 'SettingsSegmentedControl') diff --git a/src/renderer/src/components/settings/TerminalThemeSections.tsx b/src/renderer/src/components/settings/TerminalThemeSections.tsx index 9479b5f1738..1fa765bb271 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.tsx +++ b/src/renderer/src/components/settings/TerminalThemeSections.tsx @@ -12,12 +12,56 @@ import { TerminalSettingsPreview } from './TerminalSettingsPreview' import { WarpThemeImportButton } from './WarpThemeImportButton' import { YamlThemeImportButton } from './YamlThemeImportButton' import type { UseWarpThemeImportReturn } from './useWarpThemeImport' -import { getAvailableTerminalThemeOptions } from '@/lib/terminal-theme' +import { + DEFAULT_TERMINAL_THEME_DARK, + DEFAULT_TERMINAL_THEME_LIGHT, + getAvailableTerminalThemeOptions, + resolveEffectiveTerminalAppearance +} from '@/lib/terminal-theme' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' type TerminalThemeTarget = 'dark' | 'light' +let lastEditedTerminalThemeTarget: TerminalThemeTarget | null = null + +function rememberTerminalThemeTarget(target: TerminalThemeTarget): void { + lastEditedTerminalThemeTarget = target +} + +export function resetTerminalThemeTargetMemoryForTests(): void { + lastEditedTerminalThemeTarget = null +} + +function isCustomizedTheme(themeName: string, defaultThemeName: string): boolean { + const trimmed = themeName.trim() + return trimmed.length > 0 && trimmed !== defaultThemeName +} + +function getInitialTerminalThemeTarget( + settings: GlobalSettings, + systemPrefersDark: boolean, + preferredTarget?: TerminalThemeTarget +): TerminalThemeTarget { + if (preferredTarget) { + return preferredTarget + } + if (lastEditedTerminalThemeTarget) { + return lastEditedTerminalThemeTarget + } + const customizedDark = isCustomizedTheme(settings.terminalThemeDark, DEFAULT_TERMINAL_THEME_DARK) + const customizedLight = isCustomizedTheme( + settings.terminalThemeLight, + DEFAULT_TERMINAL_THEME_LIGHT + ) + if (customizedDark !== customizedLight) { + return customizedDark ? 'dark' : 'light' + } + // Why: the picker edits mode-specific slots. Defaulting to the active + // terminal mode makes the selected theme apply immediately in system-light. + return resolveEffectiveTerminalAppearance(settings, systemPrefersDark).mode +} + type TerminalThemeCatalogSectionProps = { settings: GlobalSettings systemPrefersDark: boolean @@ -45,7 +89,13 @@ export function TerminalThemeCatalogSection({ preferredTarget, advancedContent }: TerminalThemeCatalogSectionProps): React.JSX.Element { - const [target, setTarget] = useState(preferredTarget ?? 'dark') + const [target, setTargetState] = useState(() => + getInitialTerminalThemeTarget(settings, systemPrefersDark, preferredTarget) + ) + const setTarget = (nextTarget: TerminalThemeTarget): void => { + rememberTerminalThemeTarget(nextTarget) + setTargetState(nextTarget) + } const themeOptions = getAvailableTerminalThemeOptions(settings) const isLightTarget = target === 'light' const matchDarkMode = !settings.terminalUseSeparateLightTheme @@ -200,11 +250,12 @@ export function TerminalThemeCatalogSection({ themeOptions={themeOptions} query={themeSearch} onQueryChange={setThemeSearch} - onSelectTheme={(theme) => + onSelectTheme={(theme) => { + rememberTerminalThemeTarget(target) updateSettings( isLightTarget ? { terminalThemeLight: theme } : { terminalThemeDark: theme } ) - } + }} importedHighlightSignal={importedHighlightSignal} /> From c8e571f40c03a9547721f455d4e70bf543a11f15 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:07:17 -0700 Subject: [PATCH 67/70] fix(sidebar): prevent hovercard text selection drags (#7228) --- .../worktree-sidebar-pointer-drag-dom.test.ts | 43 +++++++++++++++++++ .../worktree-sidebar-pointer-drag-dom.ts | 10 ++++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts new file mode 100644 index 00000000000..1da451ff024 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { isSidebarPointerDragBlocked } from './worktree-sidebar-pointer-drag-dom' + +describe('worktree sidebar pointer drag DOM guards', () => { + it('allows plain row targets to start pointer drags', () => { + const row = document.createElement('div') + const card = document.createElement('div') + row.appendChild(card) + + expect(isSidebarPointerDragBlocked(card, row)).toBe(false) + }) + + it('blocks portaled hover card targets outside the row', () => { + const row = document.createElement('div') + const hoverCardContent = document.createElement('div') + document.body.append(row, hoverCardContent) + + expect(isSidebarPointerDragBlocked(hoverCardContent, row)).toBe(true) + + hoverCardContent.remove() + row.remove() + }) + + it('blocks interactive targets inside the row', () => { + const row = document.createElement('div') + const button = document.createElement('button') + row.appendChild(button) + + expect(isSidebarPointerDragBlocked(button, row)).toBe(true) + }) + + it('blocks icon targets inside interactive row controls', () => { + const row = document.createElement('div') + const button = document.createElement('button') + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + button.appendChild(icon) + row.appendChild(button) + + expect(isSidebarPointerDragBlocked(icon, row)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts index 740a32abf92..15d102ac3b5 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts @@ -17,7 +17,15 @@ const INTERACTIVE_DRAG_BLOCKER_SELECTOR = [ ].join(',') export function isSidebarPointerDragBlocked(target: EventTarget | null, row: HTMLElement): boolean { - if (!(target instanceof HTMLElement)) { + if (!(target instanceof Node)) { + return false + } + // Why: Radix hover cards portal outside the row, but React still bubbles their + // pointer events through row handlers; text selection there must not drag rows. + if (!row.contains(target)) { + return true + } + if (!(target instanceof Element)) { return false } const blocker = target.closest(INTERACTIVE_DRAG_BLOCKER_SELECTOR) From a4f9dda71ada934809d4b97eb5f81a9ddfb3de45 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:57:58 +0000 Subject: [PATCH 68/70] release: v1.4.121-rc.1 [rc-slot:2026-07-03-03] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 60184c71afe..858ae4e230b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120", + "version": "1.4.121-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 14034678cc7973834000b79e68b9a99a32a8375d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:56:28 +0000 Subject: [PATCH 69/70] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index f83dbfcb7ae..ff7c9fd7520 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.3m + + downloads: 3.4m @@ -15,7 +15,7 @@ downloads downloads - 3.3m - 3.3m + 3.4m + 3.4m From 96481b1eab3363a3df1175de7cbd6ab9324ed0f2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:03:28 -0700 Subject: [PATCH 70/70] Stop scheduled release cuts (#7258) --- .github/workflows/release-cut.yml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index a7b8fe1814b..b792dd9cf47 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1,6 +1,6 @@ name: Cut Release -# Why: single entry point for manually and scheduled cutting releases. +# Why: single entry point for manually cutting releases. # Replaces the old local `pnpm release:*` scripts and the standalone scheduled # RC workflow so releases are always reproducible from CI and can never be # accidentally tagged against an uncommitted or non-main working tree. @@ -37,16 +37,10 @@ on: type: string default: main dry_run: - description: Validate a scheduled-RC run without creating a tag + description: Validate an RC release cut without creating a tag required: false default: false type: boolean - schedule: - # Why: GitHub scheduled workflows are not guaranteed to start exactly on - # time and can be delayed or dropped around high-load periods. We retry - # across the full target hour and dedupe per PT slot. Cron is UTC, so run - # during both PST and PDT equivalents and let the PT-hour gate decide. - - cron: '*/5 10,11,22,23 * * *' permissions: contents: write @@ -182,8 +176,8 @@ jobs: - name: Publish complete release-cut RC drafts from prior runs id: publish_drafts - # Why: this must run even when the current slot already cut a tag; a - # later cron retry may be the first chance to unstick a complete RC draft. + # Why: a manual RC dispatch should unstick any complete RC draft before + # deciding whether to cut another tag. if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Z-%z0d!Y%55-k{&pyjb7>(oG?j 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 Z`+t-TKbBqUhHwA?002ovPDHLkV1i0z#=QUl literal 62128 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 Date: Fri, 3 Jul 2026 01:32:22 -0700 Subject: [PATCH 63/70] Restore desktop terminal URL modifier clicks Restore desktop terminal URL and OSC link activation to require Cmd/Ctrl while preserving mobile tap routing. --- .../terminal-pane/terminal-link-activation.ts | 6 ++ .../terminal-link-handlers.test.ts | 80 +++++++++++++++++-- .../terminal-pane/terminal-link-handlers.ts | 12 +-- .../terminal-pane/terminal-link-open-hints.ts | 4 +- .../terminal-osc-link-routing.ts | 26 +++--- .../terminal-url-link-hit-testing.ts | 16 ++-- .../use-terminal-pane-lifecycle.ts | 12 ++- .../pane-manager/pane-dom-creation.test.ts | 44 +++++++++- .../src/lib/pane-manager/pane-dom-creation.ts | 4 +- 9 files changed, 162 insertions(+), 42 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-activation.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts new file mode 100644 index 00000000000..e85da903224 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts @@ -0,0 +1,6 @@ +export function isTerminalLinkActivation( + event: Pick | undefined +): boolean { + const isMac = navigator.userAgent.includes('Mac') + return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 5cbc01a0903..c75dd3943e7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -7,6 +7,7 @@ import { createFilePathLinkProvider, getTerminalFileOpenHint, getTerminalHtmlFileOpenHint, + getTerminalUrlOpenHint, installFilePathLinkClickFallback, isTerminalLinkActivation, openFilePathLinkAtBufferPosition, @@ -156,12 +157,28 @@ describe('isTerminalLinkActivation', () => { }) describe('handleOscLink', () => { - it('routes http links on ordinary click', () => { + it('ignores http links without the platform modifier on desktop', () => { setPlatform('Macintosh') storeState.settings = { openLinksInApp: true } const preventDefault = vi.fn() - handleOscLink('https://example.com', { metaKey: false, ctrlKey: false, preventDefault }, deps) + expect( + handleOscLink('https://example.com', { metaKey: false, ctrlKey: false, preventDefault }, deps) + ).toBe(false) + + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('routes http links with the platform modifier on desktop', () => { + setPlatform('Macintosh') + storeState.settings = { openLinksInApp: true } + const preventDefault = vi.fn() + + expect( + handleOscLink('https://example.com', { metaKey: true, ctrlKey: false, preventDefault }, deps) + ).toBe(true) expect(openUrlMock).not.toHaveBeenCalled() expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { @@ -438,18 +455,38 @@ describe('handleOscLink', () => { setPlatform('Macintosh') expect(getTerminalFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default app') expect(getTerminalHtmlFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default browser') + expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser') setPlatform('Windows') expect(getTerminalFileOpenHint()).toBe('Ctrl+click to open or Shift+Ctrl+click for default app') expect(getTerminalHtmlFileOpenHint()).toBe( 'Ctrl+click to open or Shift+Ctrl+click for default browser' ) + expect(getTerminalUrlOpenHint()).toBe( + 'Ctrl+click to open or Shift+Ctrl+click for system browser' + ) }) - it('opens local file URL links in Orca on ordinary click', async () => { + it('ignores local file URL links without the platform modifier on desktop', async () => { setPlatform('Windows') - handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: false }, deps) + expect(handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: false }, deps)).toBe( + false + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(authorizeExternalPathMock).not.toHaveBeenCalled() + expect(openFileMock).not.toHaveBeenCalled() + expect(openFilePathMock).not.toHaveBeenCalled() + }) + + it('opens local file URL links in Orca with the platform modifier on desktop', async () => { + setPlatform('Windows') + + expect(handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: true }, deps)).toBe( + true + ) // openDetectedFilePath is async (fire-and-forget), so flush the microtask queue // before asserting on positive behavior. @@ -1708,7 +1745,7 @@ describe('createFilePathLinkProvider range bounds', () => { disposable.dispose() }) - it('opens regular URLs from a direct ordinary-click fallback when xterm did not handle them', async () => { + it('ignores regular URLs from a direct ordinary-click fallback on desktop', async () => { setPlatform('Macintosh') storeState.settings = { openLinksInApp: false } const rows = [ @@ -1732,6 +1769,39 @@ describe('createFilePathLinkProvider range bounds', () => { stopPropagation } as unknown as MouseEvent) + expect(openUrlMock).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + expect(stopPropagation).not.toHaveBeenCalled() + expect(terminal.clearSelection).not.toHaveBeenCalled() + + disposable.dispose() + expect(element.removeEventListener).toHaveBeenCalledWith('mouseup', mouseUp) + }) + + it('opens regular URLs from a direct modifier-click fallback when xterm did not handle them', async () => { + setPlatform('Macintosh') + storeState.settings = { openLinksInApp: false } + const rows = [ + makeBufferLine('PR opened: https://github.com/stablyai/orca-marketing-website/pull/82') + ] + const { terminal, element } = makeFallbackTerminal(rows) + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseUp = getRegisteredBubbleMouseUpHandler(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + + mouseUp({ + button: 0, + metaKey: true, + ctrlKey: false, + shiftKey: false, + defaultPrevented: false, + clientX: 230, + clientY: 25, + preventDefault, + stopPropagation + } as unknown as MouseEvent) + expect(openUrlMock).toHaveBeenCalledWith( 'https://github.com/stablyai/orca-marketing-website/pull/82' ) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 0e111027e37..462dbdeeedc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -33,14 +33,15 @@ import { getTerminalOrcaFileOpenHint, getTerminalWorktreePathOpenHint, getTerminalFileOpenHint, - getTerminalUrlOpenHint, - isMacPlatform + getTerminalUrlOpenHint } from './terminal-link-open-hints' import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link' +import { isTerminalLinkActivation } from './terminal-link-activation' export { openDetectedFilePath } from './terminal-file-open-routing' export { openFilePathLinkAtBufferPosition } from './terminal-file-link-hit-testing' export { getTerminalFileOpenHint, getTerminalHtmlFileOpenHint, getTerminalUrlOpenHint } +export { isTerminalLinkActivation } from './terminal-link-activation' export type LinkHandlerDeps = { worktreeId: string @@ -315,10 +316,3 @@ export function installFilePathLinkClickFallback( } } } - -export function isTerminalLinkActivation( - event: Pick | undefined -): boolean { - const isMac = isMacPlatform() - return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) -} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts index 246333c7cbd..0d51669a6c6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts @@ -22,8 +22,8 @@ export function getTerminalHtmlFileOpenHint(): string { export function getTerminalUrlOpenHint(): string { return isMacPlatform() - ? 'click to open or ⇧+click for system browser' - : 'click to open or Shift+click for system browser' + ? '⌘+click to open or ⇧⌘+click for system browser' + : 'Ctrl+click to open or Shift+Ctrl+click for system browser' } export function getTerminalUrlSystemBrowserHint(): string { diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index 9d588b7857a..2b043d648cf 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -3,6 +3,7 @@ import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-pat import type { LinkHandlerDeps } from './terminal-link-handlers' import { resolveTerminalFileUrlTarget } from './terminal-file-url-target' import { openDetectedFilePath } from './terminal-file-open-routing' +import { isTerminalLinkActivation } from './terminal-link-activation' import { openTerminalHttpLink, type TerminalLinkRoutingPreferenceRequester @@ -11,16 +12,16 @@ import { type TerminalLinkEvent = Pick & Partial> -function isPrimaryOscLinkActivation(event: TerminalLinkEvent | undefined): boolean { +function isDesktopOscLinkActivation(event: TerminalLinkEvent | undefined): boolean { if (!event) { return false } if ('button' in event && event.button !== undefined && event.button !== 0) { return false } - // Why: macOS Ctrl-click is a context-menu gesture even when Chromium reports - // it as button 0; ordinary OSC links should not steal that secondary action. - return !(navigator.userAgent.includes('Mac') && event.ctrlKey && !event.metaKey) + // Why: desktop xterm links must not open while the user is just placing the + // cursor or selecting text. Mobile URL taps use a separate WebView path. + return isTerminalLinkActivation(event) } export function handleOscLink( @@ -30,9 +31,9 @@ export function handleOscLink( Partial> & { requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester } -): void { - if (!isPrimaryOscLinkActivation(event)) { - return +): boolean { + if (!isDesktopOscLinkActivation(event)) { + return false } // Why: xterm renders OSC 8 links as clickable anchors. Orca must suppress // default anchor navigation so link-routing settings can choose the target. @@ -67,15 +68,14 @@ export function handleOscLink( ) { // Why: `new URL("C:\\path\\file.ts")` succeeds with protocol `c:`; // Windows OSC links need file-path routing before generic URL parsing. - return + return true } let parsed: URL try { parsed = new URL(rawText) } catch { - openDetectedPathLink() - return + return openDetectedPathLink() } if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { @@ -84,7 +84,7 @@ export function handleOscLink( forceSystemBrowser: Boolean(event?.shiftKey), requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference }) - return + return true } if (parsed.protocol === 'file:') { @@ -99,11 +99,13 @@ export function handleOscLink( !deps.runtimeEnvironmentId const resolved = resolveTerminalFileUrlTarget(parsed, { allowUncHost }) if (!resolved) { - return + return false } openDetectedFilePath(resolved.filePath, resolved.line, resolved.column, { ...deps, openWithSystemDefault: Boolean(event?.shiftKey) }) + return true } + return false } diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index 96ceafcce38..67505d5f622 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -1,6 +1,7 @@ import type { IBufferLine, IBufferRange, IDisposable, Terminal } from '@xterm/xterm' import { openHttpLink } from '@/lib/http-link-routing' import { buildCandidateLogicalLinesForBufferPosition } from './terminal-file-link-hit-testing' +import { isTerminalLinkActivation } from './terminal-link-activation' import { rangeForParsedFileLink } from './wrapped-terminal-link-ranges' type UrlLinkHitTestDeps = { @@ -48,13 +49,14 @@ export function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLi return links } -function isPrimaryHttpLinkFallbackActivation(event: MouseEvent): boolean { +function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean { if (event.defaultPrevented || event.button !== 0) { return false } - // Why: URL links now open on ordinary clicks, but macOS Ctrl-click must stay - // available for context menus even when Chromium reports it as button 0. - return !(navigator.userAgent.includes('Mac') && event.ctrlKey && !event.metaKey) + // Why: desktop terminal links require an intentional Cmd/Ctrl gesture so + // plain clicks remain available for cursor placement and selection. Mobile + // tap routing is handled separately under mobile/src/terminal. + return isTerminalLinkActivation(event) } function* iterateTerminalHttpUrlCandidates( @@ -220,7 +222,7 @@ export function installHttpLinkClickFallback( deps: UrlLinkClickFallbackDeps ): IDisposable { const handleMouseUp = (event: MouseEvent): void => { - if (!isPrimaryHttpLinkFallbackActivation(event)) { + if (!isDesktopHttpLinkFallbackActivation(event)) { return } @@ -230,8 +232,8 @@ export function installHttpLinkClickFallback( } // Why: xterm's WebLinksAddon only activates after hover state exists. This - // direct mouseup fallback preserves ordinary link clicks when the hover link - // was never established, while defaultPrevented avoids duplicate opens. + // direct mouseup fallback preserves modifier-clicks when the hover link was + // never established, while defaultPrevented avoids duplicate opens. const opened = openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, { worktreeId: deps.worktreeId, forceSystemBrowser: event.shiftKey, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index a5d9172855b..1674e150fcd 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -1017,7 +1017,7 @@ export function useTerminalPaneLifecycle({ pane.terminal.options.linkHandler = { allowNonHttpProtocols: true, activate: (event, text) => { - handleOscLink(text, event as MouseEvent | undefined, { + const handled = handleOscLink(text, event as MouseEvent | undefined, { ...linkDeps, startupCwd: getPaneLinkCwd(pane.id), runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null, @@ -1031,7 +1031,9 @@ export function useTerminalPaneLifecycle({ // moving the mouse extends a selection until the next click/Esc. // clearSelection() explicitly detaches those listeners (see // SelectionService._removeMouseDownListeners). - pane.terminal.clearSelection() + if (handled) { + pane.terminal.clearSelection() + } }, // Show bottom-left tooltip on hover for OSC 8 hyperlinks (e.g. // GitHub owner/repo#issue references emitted by CLI tools) — same @@ -1344,7 +1346,7 @@ export function useTerminalPaneLifecycle({ return } const activePane = managerRef.current?.getActivePane() - void handleOscLink(url, event, { + const handled = handleOscLink(url, event, { ...linkDeps, startupCwd: activePane ? getPaneLinkCwd(activePane.id) : startupCwd, runtimeEnvironmentId: activePane @@ -1359,7 +1361,9 @@ export function useTerminalPaneLifecycle({ // phantom selection until the next click/Esc. Explicitly clearing the // selection also detaches those listeners (see // SelectionService._removeMouseDownListeners). - managerRef.current?.getActivePane()?.terminal.clearSelection() + if (handled) { + managerRef.current?.getActivePane()?.terminal.clearSelection() + } }, formatLinkTooltip: (url, openLinkHint) => formatTerminalUrlTooltip(url, openLinkHint), // Why: TerminalPane instances stay mounted for hidden visited worktrees diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts index ec1783654d5..7f256d4304a 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { createPaneDOM } from './pane-dom-creation' @@ -48,7 +48,49 @@ vi.mock('@xterm/xterm', () => ({ }) })) +function setPlatform(userAgent: string): void { + vi.stubGlobal('navigator', { userAgent }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + describe('createPaneDOM link tooltips', () => { + it('uses desktop modifier-click text for WebLinks hover hints', () => { + const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + + setPlatform('Macintosh') + const macPane = createPaneDOM( + 1, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + webLinksAddonMock.options?.hover?.({} as MouseEvent, 'http://localhost:5180/') + expect(macPane.linkTooltip.textContent).toBe( + 'http://localhost:5180/ (⌘+click to open or ⇧⌘+click for system browser)' + ) + + setPlatform('Windows') + const windowsPane = createPaneDOM( + 2, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + webLinksAddonMock.options?.hover?.({} as MouseEvent, 'http://localhost:5180/') + expect(windowsPane.linkTooltip.textContent).toBe( + 'http://localhost:5180/ (Ctrl+click to open or Shift+Ctrl+click for system browser)' + ) + }) + it('lets callers replace WebLinks hover text for display-only labels', async () => { const labeledText = 'http://main.orca.localhost:60016/ (localhost:5180; click to open)' const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 8c1c1e289ff..2e556f44400 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -16,8 +16,8 @@ import { installGuardedLinkProviderRegistration } from './terminal-link-provider function getTerminalUrlOpenHint(): string { return navigator.userAgent.includes('Mac') - ? 'click to open or ⇧+click for system browser' - : 'click to open or Shift+click for system browser' + ? '⌘+click to open or ⇧⌘+click for system browser' + : 'Ctrl+click to open or Shift+Ctrl+click for system browser' } function defaultLinkTooltipText(uri: string, openLinkHint: string): string { From a84f737cf79e6dece80c7071e450f6facfe8bd67 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:45:15 -0700 Subject: [PATCH 64/70] fix(terminals): restore pane detach tab placement (#7215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tabs): restore deferred tab activation so dragging a terminal tab doesn't switch panes mid-gesture PR #5927 shipped terminal-pane drag (reorder tabs, move a tab into another pane, edge-split into a new column). It deliberately DEFERRED tab activation to pointer-up and suppressed it during a drag, so pressing a tab to drag it never switched the active tab or stole terminal focus mid-gesture. PR #6395 (d32d62a395) deleted tab-strip-pointer-activation.ts and made SortableTab/EditorFileTab/BrowserTab activate eagerly on pointerdown — fixing click-to-switch-after-reorder but regressing the drag: grabbing a tab now flips the active tab + focused pane and yanks xterm keyboard focus before the drag is even recognized (aggravated by PR #6210 raising the dnd-kit threshold 5px->12px). The move itself still lands, but the gesture feels broken. Restore the deferred-activation hook, but gate it on measured pointer DISPLACEMENT instead of the drag-active context ref the old hook used — that ref clears asynchronously relative to the drop's pointerup, which is exactly what made #6395's click-after-reorder misfire. Displacement mirrors dnd-kit's own activation threshold: a release within TAB_DRAG_ACTIVATION_DISTANCE_PX is a click (activate); crossing it is a drag (suppress). Because each press measures its own gesture, a click after a reorder always activates. - Recreate src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts (displacement-gated) + unit tests, incl. an explicit #6395 regression case. - Rewire onPointerDown in SortableTab, EditorFileTab, BrowserTab to use it. - BrowserTab.test.tsx shallow-renders via mocked React hooks; add useRef/ useCallback stubs so the new hook works under that harness. Verified end-to-end in a dev build via CDP: pressing a tab no longer activates/steals focus on pointerdown; a click still activates on release; dragging a tab into another pane still moves it; clicking tabs after a drag still switches (the #6395 guard in tests/e2e/tabs.spec.ts stays green because it uses zero-displacement clicks). 233 unit tests pass; oxlint + renderer typecheck clean. * fix: restore pane detach tab placement --- src/renderer/src/assets/terminal.css | 6 + .../components/tab-bar/BrowserTab.test.tsx | 5 + .../src/components/tab-bar/BrowserTab.tsx | 14 +- .../src/components/tab-bar/EditorFileTab.tsx | 16 +- .../src/components/tab-bar/SortableTab.tsx | 20 +- .../tab-strip-pointer-activation.test.tsx | 111 +++++ .../tab-bar/tab-strip-pointer-activation.ts | 91 +++++ .../components/tab-group/TabGroupPanel.tsx | 2 + .../components/terminal-pane/TerminalPane.tsx | 63 ++- .../terminal-layout-leaf-detach.test.ts | 119 ++++++ .../terminal-layout-leaf-detach.ts | 119 ++++++ .../terminal-pane-tab-detach.test.ts | 383 ++++++++++++++++++ .../terminal-pane/terminal-pane-tab-detach.ts | 306 ++++++++++++++ .../use-terminal-pane-lifecycle.ts | 55 ++- .../src/lib/pane-manager/pane-drag-pointer.ts | 50 ++- .../pane-manager/pane-drag-reorder.test.ts | 74 ++++ .../src/lib/pane-manager/pane-drag-reorder.ts | 16 +- .../lib/pane-manager/pane-manager-types.ts | 20 + .../src/lib/pane-manager/pane-manager.ts | 41 +- .../src/lib/pane-manager/pane-split-close.ts | 31 +- .../src/store/slices/store-cascades.test.ts | 51 +++ src/renderer/src/store/slices/terminals.ts | 84 ++++ 22 files changed, 1624 insertions(+), 53 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 97d3cbb4744..d9763b3e341 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -219,6 +219,12 @@ pointer-events: none; } +.pane-drop-overlay[data-pane-drop-overlay-kind='insertion'] { + background: var(--chart-2); + border: 0; + border-radius: 0; +} + .tab-drop-overlay__label { font-size: 11px; color: var(--primary-foreground); diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index a53e80e0117..f4dc863f8e8 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -11,6 +11,11 @@ vi.mock('react', async () => { return { ...actual, useEffect: () => {}, + // Why: this shallow harness calls the component as a plain function (no React + // render), so ref/callback hooks must be stubbed like useState/useEffect. The + // favicon tests never fire pointer events, so non-persistent refs are fine. + useRef: (initial: T) => ({ current: initial }), + useCallback: (fn: T) => fn, useState(initial: T | (() => T)) { const stateIndex = reactHookRuntime.index++ if (!(stateIndex in reactHookRuntime.states)) { diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 83ad2c5f45f..0dfb97a9af6 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -26,6 +26,7 @@ import { preventMiddleButtonDefault } from './middle-button-default-guard' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' function formatBrowserTabUrlLabel(url: string): string { if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') { @@ -169,6 +170,10 @@ export default function BrowserTab({ return () => window.removeEventListener('blur', dismiss) }, [menuOpen]) + // Why: defer activation to pointer-up so dragging the tab (reorder / move into + // another pane / split) does not switch the active tab mid-gesture. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ onActivate }) + const tabRoot = (