From f534cd5bc3a9f35eb4cade2bc19a2f26c513e565 Mon Sep 17 00:00:00 2001 From: Carlos Baraza Date: Fri, 19 Jun 2026 03:25:06 +0200 Subject: [PATCH 001/163] fix(mobile): downscale oversized clipboard images so paste no longer fails (#5601) * fix(mobile): downscale oversized clipboard images so paste no longer fails Pasting a large clipboard image (high-res screenshot or photo) failed with 'Image too large to paste' because the re-encoded PNG exceeded the 24 MiB base64 upload cap, with nothing shrinking it first. Now an oversized image is downscaled to fit the budget before upload: - computeMobileClipboardImageDownscale() picks target dimensions by area (~sqrt(budget/actual)), with a bounded retry loop since PNG size is nonlinear. - prepareMobileClipboardImageBase64() drives the loop with an injected resizer, so the byte/dimension logic is unit-tested without native modules. - The resizer stages the image to a temp file and hands ImageManipulator a file:// URI; the iOS native loader (Data(contentsOf:)) cannot decode large base64 data URIs, so a data URI made renderAsync throw. Adds expo-image-manipulator and expo-file-system. * fix(mobile): fail fast when resized clipboard image has no base64 Empty base64 from saveAsync would pass the downstream base64 check and upload a corrupt image; throw instead so the paste surfaces an error. Addresses CodeRabbit review on #5601. * Fix mobile clipboard image resize cleanup Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../app/h/[hostId]/session/[worktreeId].tsx | 55 +++++++++++++- mobile/package.json | 2 + mobile/pnpm-lock.yaml | 16 ++++ .../session/mobile-clipboard-image.test.ts | 61 ++++++++++++++++ mobile/src/session/mobile-clipboard-image.ts | 73 +++++++++++++++++++ 5 files changed, 205 insertions(+), 2 deletions(-) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index d2fe6201f05..7add5207c88 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1,6 +1,8 @@ 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, @@ -146,7 +148,9 @@ import { } from '../../../../src/session/mobile-new-tab-agent-options' import { buildMobileImagePastePayload, - saveMobileClipboardImageAsTempFile + prepareMobileClipboardImageBase64, + saveMobileClipboardImageAsTempFile, + type MobileClipboardImageResizer } from '../../../../src/session/mobile-clipboard-image' import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment' import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind' @@ -204,6 +208,52 @@ import type { TerminalGestureInputQueue } from './mobile-session-route-types' +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 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. + } + } +} + function getActiveTabIdForHandle( tabs: MobileSessionTab[], terminalHandle: string | null @@ -3474,7 +3524,8 @@ export default function SessionScreen() { return } const connectionId = await getActiveWorktreeConnectionId() - const imagePath = await saveMobileClipboardImageAsTempFile(client, image.data, { + const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage) + const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, { connectionId }) payload = buildMobileImagePastePayload(imagePath) diff --git a/mobile/package.json b/mobile/package.json index 643749e9456..81587560f62 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -26,7 +26,9 @@ "expo-crypto": "^55.0.14", "expo-dev-client": "~55.0.35", "expo-document-picker": "^55.0.13", + "expo-file-system": "55.0.19", "expo-haptics": "^55.0.14", + "expo-image-manipulator": "^55.0.17", "expo-image-picker": "^55.0.20", "expo-linking": "^55.0.15", "expo-modules-core": "~55.0.25", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 68e5ea7c27e..a3c2587271c 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -41,9 +41,15 @@ importers: expo-document-picker: specifier: ^55.0.13 version: 55.0.13(expo@55.0.23) + expo-file-system: + specifier: 55.0.19 + version: 55.0.19(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)) expo-haptics: specifier: ^55.0.14 version: 55.0.14(expo@55.0.23) + expo-image-manipulator: + specifier: ^55.0.17 + version: 55.0.17(expo@55.0.23) expo-image-picker: specifier: ^55.0.20 version: 55.0.20(expo@55.0.23) @@ -3378,6 +3384,11 @@ packages: peerDependencies: expo: '*' + expo-image-manipulator@55.0.17: + resolution: {integrity: sha512-lTYhmXejHnMp+vjFID0Q/jD+Qic3U9OP4qw3tCVHCVp4TTmx9hbifTIN0hmw02ak7LsBkxuYnP+uglvUKgJAzA==} + peerDependencies: + expo: '*' + expo-image-picker@55.0.20: resolution: {integrity: sha512-lfWt/0rPWdKz8AdDEGmGHZIJSNlVc720Dlx5bfou10FU16ZV5wAbTU63nm2jkXd8hbXke4a/2Ha1dzxCVA+LQQ==} peerDependencies: @@ -9960,6 +9971,11 @@ snapshots: dependencies: expo: 55.0.23(@babel/core@7.29.0)(@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.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-image-manipulator@55.0.17(expo@55.0.23): + dependencies: + expo: 55.0.23(@babel/core@7.29.0)(@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.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-image-loader: 55.0.1(expo@55.0.23) + expo-image-picker@55.0.20(expo@55.0.23): dependencies: expo: 55.0.23(@babel/core@7.29.0)(@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.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) diff --git a/mobile/src/session/mobile-clipboard-image.test.ts b/mobile/src/session/mobile-clipboard-image.test.ts index b7a7edb6922..c0cc283fe5c 100644 --- a/mobile/src/session/mobile-clipboard-image.test.ts +++ b/mobile/src/session/mobile-clipboard-image.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { buildMobileImagePastePayload, + computeMobileClipboardImageDownscale, MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, normalizeMobileClipboardImageBase64, + prepareMobileClipboardImageBase64, saveMobileClipboardImageAsTempFile } from './mobile-clipboard-image' import type { RpcClient } from '../transport/rpc-client' @@ -127,3 +129,62 @@ describe('mobile clipboard image paste helpers', () => { expect(buildMobileImagePastePayload('/tmp/\x1b.png')).toBe('\x1b[200~/tmp/\u241b.png\x1b[201~') }) }) + +describe('mobile clipboard image downscaling', () => { + it('does not downscale images already within the byte budget', () => { + expect(computeMobileClipboardImageDownscale(50, 100, 100, 100)).toBeNull() + }) + + it('shrinks both edges by ~sqrt(budget/actual) when over the budget', () => { + // 400 base64 chars vs 100 budget -> scale sqrt(0.25) * 0.85 safety = 0.425 + // 40 * 0.425 = 17, 20 * 0.425 = 8.5 -> floor 8 + expect(computeMobileClipboardImageDownscale(400, 40, 20, 100)).toEqual({ width: 17, height: 8 }) + }) + + it('refuses to downscale when source dimensions are unusable', () => { + expect(computeMobileClipboardImageDownscale(400, 0, 20, 100)).toBeNull() + expect(computeMobileClipboardImageDownscale(400, 40, -1, 100)).toBeNull() + }) + + it('returns the original base64 untouched when within budget', async () => { + const resize = vi.fn() + const data = `data:image/png;base64,${'a'.repeat(40)}` + await expect( + prepareMobileClipboardImageBase64({ data, size: { width: 10, height: 10 } }, resize, 100) + ).resolves.toBe(data) + expect(resize).not.toHaveBeenCalled() + }) + + it('downscales oversized images in one pass when the result fits', async () => { + const resize = vi.fn(async () => ({ data: 'b'.repeat(50), width: 17, height: 8 })) + const data = `data:image/png;base64,${'a'.repeat(400)}` + await expect( + prepareMobileClipboardImageBase64({ data, size: { width: 40, height: 20 } }, resize, 100) + ).resolves.toBe('b'.repeat(50)) + expect(resize).toHaveBeenCalledTimes(1) + expect(resize).toHaveBeenCalledWith(data, { width: 17, height: 8 }) + }) + + it('retries downscaling, feeding back result dimensions, until it fits', async () => { + const resize = vi + .fn() + .mockResolvedValueOnce({ data: 'b'.repeat(150), width: 17, height: 8 }) + .mockResolvedValueOnce({ data: 'c'.repeat(40), width: 7, height: 3 }) + const data = `data:image/png;base64,${'a'.repeat(400)}` + await expect( + prepareMobileClipboardImageBase64({ data, size: { width: 40, height: 20 } }, resize, 100) + ).resolves.toBe('c'.repeat(40)) + expect(resize).toHaveBeenCalledTimes(2) + expect(resize.mock.calls[1][0]).toBe('b'.repeat(150)) + expect(resize.mock.calls[1][1]).toEqual({ width: 11, height: 5 }) + }) + + it('gives up after bounded attempts and lets the downstream cap reject it', async () => { + const resize = vi.fn(async () => ({ data: 'b'.repeat(150), width: 5, height: 5 })) + const data = `data:image/png;base64,${'a'.repeat(400)}` + await expect( + prepareMobileClipboardImageBase64({ data, size: { width: 40, height: 20 } }, resize, 100) + ).resolves.toBe('b'.repeat(150)) + expect(resize).toHaveBeenCalledTimes(3) + }) +}) diff --git a/mobile/src/session/mobile-clipboard-image.ts b/mobile/src/session/mobile-clipboard-image.ts index a6a8e9eee93..3bc39749ac0 100644 --- a/mobile/src/session/mobile-clipboard-image.ts +++ b/mobile/src/session/mobile-clipboard-image.ts @@ -4,6 +4,10 @@ import type { RpcFailure, RpcSuccess } from '../transport/types' export const MOBILE_CLIPBOARD_IMAGE_MAX_BASE64_CHARS = 24 * 1024 * 1024 export const MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 export const MOBILE_CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024 +// Why: PNG bytes don't scale exactly with pixel area, so undershoot the target on +// each pass and let the bounded retry below converge instead of distorting in one shot. +const MOBILE_CLIPBOARD_IMAGE_DOWNSCALE_SAFETY = 0.85 +const MOBILE_CLIPBOARD_IMAGE_MAX_DOWNSCALE_ATTEMPTS = 3 const DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ @@ -19,6 +23,75 @@ export function normalizeMobileClipboardImageBase64(data: string): string { return contentBase64 } +export type MobileClipboardImage = { + data: string + size: { width: number; height: number } +} + +export type MobileClipboardImageResizer = ( + source: string, + target: { width: number; height: number } +) => Promise<{ data: string; width: number; height: number }> + +/** + * Returns the pixel dimensions to resize a clipboard image to so its base64 fits + * the upload budget, or null when it already fits (or its dimensions are unusable). + */ +export function computeMobileClipboardImageDownscale( + base64Length: number, + width: number, + height: number, + maxBase64Length: number +): { width: number; height: number } | null { + if (base64Length <= maxBase64Length) { + return null + } + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null + } + const scale = Math.sqrt(maxBase64Length / base64Length) * MOBILE_CLIPBOARD_IMAGE_DOWNSCALE_SAFETY + const nextWidth = Math.max(1, Math.floor(width * scale)) + const nextHeight = Math.max(1, Math.floor(height * scale)) + // Guard against a no-op shrink (already 1px) so the retry loop can't spin forever. + if (nextWidth >= width && nextHeight >= height) { + return null + } + return { width: nextWidth, height: nextHeight } +} + +/** + * Downscales an oversized clipboard image until its base64 fits the upload budget, + * delegating the actual raster resize to the injected `resize`. Returns the + * upload-ready base64; if it still overflows after the bounded retries the + * downstream size check rejects it with the same "too large" error as before. + */ +export async function prepareMobileClipboardImageBase64( + image: MobileClipboardImage, + resize: MobileClipboardImageResizer, + maxBase64Length: number = MOBILE_CLIPBOARD_IMAGE_MAX_BASE64_CHARS +): Promise { + let data = image.data + let width = image.size.width + let height = image.size.height + for (let attempt = 0; attempt < MOBILE_CLIPBOARD_IMAGE_MAX_DOWNSCALE_ATTEMPTS; attempt += 1) { + const contentLength = data.replace(DATA_URL_PREFIX_RE, '').length + const target = computeMobileClipboardImageDownscale( + contentLength, + width, + height, + maxBase64Length + ) + if (!target) { + return data + } + const resized = await resize(data, target) + data = resized.data + width = resized.width + height = resized.height + } + return data +} + function assertSuccess(response: RpcSuccess | RpcFailure): T { if (!response.ok) { throw new Error(response.error.message) From b8f2f951007cbfeebd154d2d515b5c4bb84a61ec Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 19 Jun 2026 09:34:14 +0800 Subject: [PATCH 002/163] fix(mobile): sync non-markdown editor files to mobile session tabs (#5582) * fix(mobile): sync non-markdown editor files to mobile session tabs A regression stopped non-markdown files opened on desktop from reaching the mobile session-tab list: the group projection order surfaced markdown tabs but dropped plain file tabs. Append any open editor file for the worktree that the projection didn't emit, keyed by relativePath, so plain files show up on mobile again (markdown still syncs via the projection). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(mobile): preserve recovered editor session tab identity Co-authored-by: Orca * fix(mobile): tolerate legacy active tab metadata Co-authored-by: Orca --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../src/runtime/sync-runtime-graph.test.ts | 446 ++++++++++++++++++ .../src/runtime/sync-runtime-graph.ts | 238 +++++++++- 2 files changed, 675 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index ef657bf3a15..6068766a724 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -21,6 +21,8 @@ function makeState(overrides: Partial = {}): AppState { tabBarOrderByWorktree: {}, activeFileId: null, activeFileIdByWorktree: {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: {}, activeBrowserTabIdByWorktree: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, @@ -53,6 +55,8 @@ function makeSharedOverrides(): Partial { unifiedTabsByWorktree: {}, tabBarOrderByWorktree: {}, activeFileIdByWorktree: {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: {}, activeBrowserTabIdByWorktree: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, @@ -606,6 +610,448 @@ describe('buildMobileSessionTabSnapshots', () => { expect(tab).not.toHaveProperty('diffSource') }) + it('publishes a missing non-markdown editor with its unified tab id and split group', () => { + const fileId = '/repo/src/app.ts' + const state = makeState({ + activeGroupIdByWorktree: { 'wt-1': 'group-left' }, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-left', + activeTabId: 'browser-tab-1', + tabOrder: ['browser-tab-1'], + recentTabIds: ['browser-tab-1'] + }, + { + id: 'group-right', + activeTabId: 'editor-tab-1', + tabOrder: [], + recentTabIds: [] + } + ] + } as unknown as AppState['groupsByWorktree'], + layoutByWorktree: { + 'wt-1': { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-left' }, + second: { type: 'leaf', groupId: 'group-right' } + } + } as unknown as AppState['layoutByWorktree'], + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'browser-tab-1', + groupId: 'group-left', + contentType: 'browser', + entityId: 'browser-1', + title: 'Docs' + }, + { + id: 'editor-tab-1', + groupId: 'group-right', + contentType: 'editor', + entityId: fileId, + title: 'app.ts' + } + ] + } as unknown as AppState['unifiedTabsByWorktree'], + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'browser-1', + worktreeId: 'wt-1', + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'https://example.test', + title: 'Docs', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + } as unknown as AppState['browserTabsByWorktree'], + browserPagesByWorkspace: { + 'browser-1': [ + { + id: 'page-1', + workspaceId: 'browser-1', + worktreeId: 'wt-1', + url: 'https://example.test', + title: 'Docs', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + } as unknown as AppState['browserPagesByWorkspace'], + openFiles: [ + { + id: fileId, + filePath: fileId, + relativePath: 'src/app.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit', + isDirty: false + } + ] + }) + + const snapshot = buildMobileSessionTabSnapshots(state)[0] + + expect(snapshot?.tabs.map((tab) => tab.id)).toEqual(['browser-tab-1', 'editor-tab-1']) + expect(snapshot?.tabs.at(-1)).toMatchObject({ + type: 'file', + id: 'editor-tab-1', + relativePath: 'src/app.ts', + isActive: false + }) + expect(snapshot?.activeTabId).toBe('browser-tab-1') + expect(snapshot?.tabGroups).toEqual([ + { + id: 'group-left', + activeTabId: 'browser-tab-1', + tabOrder: ['browser-tab-1'], + recentTabIds: ['browser-tab-1'] + }, + { + id: 'group-right', + activeTabId: 'editor-tab-1', + tabOrder: ['editor-tab-1'], + recentTabIds: [] + } + ]) + expect(snapshot?.tabGroupLayout).toEqual({ + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-left' }, + second: { type: 'leaf', groupId: 'group-right' } + }) + }) + + it('does not conflate same-path edit and diff editor tabs in the fallback', () => { + const fileId = '/repo/src/app.ts' + const diffId = 'wt-1::diff::unstaged::src/app.ts' + const state = makeState({ + activeGroupIdByWorktree: { 'wt-1': 'group-1' }, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-1', + activeTabId: 'editor-tab-1', + tabOrder: ['editor-tab-1'], + recentTabIds: ['editor-tab-1'] + } + ] + } as unknown as AppState['groupsByWorktree'], + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'editor-tab-1', + groupId: 'group-1', + contentType: 'editor', + entityId: fileId, + title: 'app.ts' + } + ] + } as unknown as AppState['unifiedTabsByWorktree'], + openFiles: [ + { + id: fileId, + filePath: fileId, + relativePath: 'src/app.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit', + isDirty: false + }, + { + id: diffId, + filePath: fileId, + relativePath: 'src/app.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'diff', + diffSource: 'unstaged', + isDirty: false + } + ] + }) + + const snapshot = buildMobileSessionTabSnapshots(state)[0] + + expect(snapshot?.tabs).toMatchObject([ + { type: 'file', id: 'editor-tab-1', mode: 'edit', relativePath: 'src/app.ts' }, + { type: 'file', id: diffId, mode: 'diff', diffSource: 'unstaged', relativePath: 'src/app.ts' } + ]) + expect(snapshot?.tabGroups).toEqual([ + { + id: 'group-1', + activeTabId: 'editor-tab-1', + tabOrder: ['editor-tab-1', diffId], + recentTabIds: ['editor-tab-1'] + } + ]) + }) + + it('recovers a duplicate split editor tab for an already-emitted file id', () => { + const fileId = '/repo/src/app.ts' + const state = makeState({ + activeGroupIdByWorktree: { 'wt-1': 'group-left' }, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-left', + activeTabId: 'editor-left', + tabOrder: ['editor-left'], + recentTabIds: ['editor-left'] + }, + { + id: 'group-right', + activeTabId: 'editor-right', + tabOrder: [], + recentTabIds: [] + } + ] + } as unknown as AppState['groupsByWorktree'], + layoutByWorktree: { + 'wt-1': { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-left' }, + second: { type: 'leaf', groupId: 'group-right' } + } + } as unknown as AppState['layoutByWorktree'], + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'editor-left', + groupId: 'group-left', + contentType: 'editor', + entityId: fileId, + title: 'app.ts' + }, + { + id: 'editor-right', + groupId: 'group-right', + contentType: 'editor', + entityId: fileId, + title: 'app.ts' + } + ] + } as unknown as AppState['unifiedTabsByWorktree'], + openFiles: [ + { + id: fileId, + filePath: fileId, + relativePath: 'src/app.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit', + isDirty: false + } + ] + }) + + const snapshot = buildMobileSessionTabSnapshots(state)[0] + + expect(snapshot?.tabs).toMatchObject([ + { type: 'file', id: 'editor-left', relativePath: 'src/app.ts' }, + { type: 'file', id: 'editor-right', relativePath: 'src/app.ts' } + ]) + expect(snapshot?.tabGroups).toEqual([ + { + id: 'group-left', + activeTabId: 'editor-left', + tabOrder: ['editor-left'], + recentTabIds: ['editor-left'] + }, + { + id: 'group-right', + activeTabId: 'editor-right', + tabOrder: ['editor-right'], + recentTabIds: [] + } + ]) + expect(snapshot?.tabGroupLayout).toEqual({ + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-left' }, + second: { type: 'leaf', groupId: 'group-right' } + }) + }) + + it('uses unified editor ids in legacy no-group order without duplicating file ids', () => { + const fileId = '/repo/src/app.ts' + const state = makeState({ + tabBarOrderByWorktree: { 'wt-1': [fileId] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'editor-tab-1', + groupId: 'group-1', + contentType: 'editor', + entityId: fileId, + title: 'app.ts' + } + ] + } as unknown as AppState['unifiedTabsByWorktree'], + openFiles: [ + { + id: fileId, + filePath: fileId, + relativePath: 'src/app.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit', + isDirty: false + } + ] + }) + + const snapshot = buildMobileSessionTabSnapshots(state)[0] + + expect(snapshot?.tabs).toMatchObject([ + { type: 'file', id: 'editor-tab-1', relativePath: 'src/app.ts' } + ]) + expect(snapshot?.tabs).toHaveLength(1) + }) + + it('recovers a missing diff unified tab in its split group', () => { + const diffId = 'wt-1::diff::unstaged::src/app.ts' + const state = makeState({ + activeGroupIdByWorktree: { 'wt-1': 'group-left' }, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-left', + activeTabId: 'terminal-left', + tabOrder: [], + recentTabIds: [] + }, + { + id: 'group-right', + activeTabId: 'diff-tab-right', + tabOrder: [], + recentTabIds: [] + } + ] + } as unknown as AppState['groupsByWorktree'], + layoutByWorktree: { + 'wt-1': { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-left' }, + second: { type: 'leaf', groupId: 'group-right' } + } + } as unknown as AppState['layoutByWorktree'], + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'diff-tab-right', + groupId: 'group-right', + contentType: 'diff', + entityId: diffId, + title: 'app.ts' + } + ] + } as unknown as AppState['unifiedTabsByWorktree'], + openFiles: [ + { + id: diffId, + filePath: '/repo/src/app.ts', + relativePath: 'src/app.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'diff', + diffSource: 'unstaged', + isDirty: false + } + ] + }) + + const snapshot = buildMobileSessionTabSnapshots(state)[0] + + expect(snapshot?.tabs).toMatchObject([ + { + type: 'file', + id: 'diff-tab-right', + mode: 'diff', + diffSource: 'unstaged', + relativePath: 'src/app.ts' + } + ]) + expect(snapshot?.tabGroups).toEqual([ + { + id: 'group-right', + activeTabId: 'diff-tab-right', + tabOrder: ['diff-tab-right'], + recentTabIds: [] + } + ]) + expect(snapshot?.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-right' }) + }) + + it('gates fallback editor active state on the worktree active tab type', () => { + const fileId = '/repo/src/app.ts' + const state = { + activeFileId: '/repo/other-worktree.ts', + activeFileIdByWorktree: { 'wt-1': fileId }, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-1', + activeTabId: fileId, + tabOrder: [], + recentTabIds: [] + } + ] + } as unknown as AppState['groupsByWorktree'], + openFiles: [ + { + id: fileId, + filePath: fileId, + relativePath: 'src/app.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit', + isDirty: false + } + ] + } satisfies Partial + + const terminalSnapshot = buildMobileSessionTabSnapshots( + makeState({ + ...state, + activeTabTypeByWorktree: { 'wt-1': 'terminal' } + }) + )[0] + const editorSnapshot = buildMobileSessionTabSnapshots( + makeState({ + ...state, + activeTabTypeByWorktree: { 'wt-1': 'editor' } + }) + )[0] + + expect(terminalSnapshot?.tabs).toMatchObject([ + { type: 'file', id: fileId, relativePath: 'src/app.ts', isActive: false } + ]) + expect(terminalSnapshot?.activeTabId).toBeNull() + expect(terminalSnapshot?.activeTabType).toBeNull() + expect(editorSnapshot?.tabs).toMatchObject([ + { type: 'file', id: fileId, relativePath: 'src/app.ts', isActive: true } + ]) + expect(editorSnapshot?.activeTabId).toBe(fileId) + expect(editorSnapshot?.activeTabType).toBe('file') + }) + it('keeps duplicate file ids scoped to their worktree', () => { const sharedRemotePath = '/home/dev/project/README.md' const previewId = `markdown-preview::${sharedRemotePath}` diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 9588aad9193..cba5040b706 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -26,6 +26,7 @@ import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' import { isClaudeManagementTitle } from '../../../shared/agent-detection' import type { + Tab, TabGroup, TabGroupLayoutNode, TerminalLayoutSnapshot, @@ -53,6 +54,10 @@ type OpenFileIndexes = { byWorktreeAndId: OpenFileByWorktreeAndId idsByWorktree: Map } +type FallbackEditorTabTarget = { + tabId: string + groupId: string | null +} type TabsProjectionCacheEntry = { tabs: NonNullable worktreeIdJson: string @@ -184,6 +189,8 @@ export type RuntimeMobileSessionSyncKey = { tabBarOrderByWorktree: AppState['tabBarOrderByWorktree'] activeFileId: AppState['activeFileId'] activeFileIdByWorktree: AppState['activeFileIdByWorktree'] + activeTabType: AppState['activeTabType'] + activeTabTypeByWorktree: AppState['activeTabTypeByWorktree'] activeTabId: AppState['activeTabId'] activeBrowserTabIdByWorktree: AppState['activeBrowserTabIdByWorktree'] agentStatusEpoch: number @@ -222,6 +229,8 @@ export function canSkipRuntimeMobileSessionSyncKeyBuild( state.tabBarOrderByWorktree === previousState.tabBarOrderByWorktree && state.activeFileId === previousState.activeFileId && state.activeFileIdByWorktree === previousState.activeFileIdByWorktree && + state.activeTabType === previousState.activeTabType && + state.activeTabTypeByWorktree === previousState.activeTabTypeByWorktree && state.browserTabsByWorktree === previousState.browserTabsByWorktree && state.browserPagesByWorkspace === previousState.browserPagesByWorkspace && state.activeBrowserTabIdByWorktree === previousState.activeBrowserTabIdByWorktree && @@ -274,6 +283,8 @@ export function getRuntimeMobileSessionSyncKey( tabBarOrderByWorktree: state.tabBarOrderByWorktree, activeFileId: state.activeFileId, activeFileIdByWorktree: state.activeFileIdByWorktree, + activeTabType: state.activeTabType, + activeTabTypeByWorktree: state.activeTabTypeByWorktree, activeTabId: state.activeTabId, activeBrowserTabIdByWorktree: state.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE, @@ -478,6 +489,8 @@ export function runtimeMobileSessionSyncKeysEqual( a.tabBarOrderByWorktree === b.tabBarOrderByWorktree && a.activeFileId === b.activeFileId && a.activeFileIdByWorktree === b.activeFileIdByWorktree && + a.activeTabType === b.activeTabType && + a.activeTabTypeByWorktree === b.activeTabTypeByWorktree && a.activeTabId === b.activeTabId && a.activeBrowserTabIdByWorktree === b.activeBrowserTabIdByWorktree && a.agentStatusEpoch === b.agentStatusEpoch && @@ -670,6 +683,8 @@ export function buildMobileSessionTabSnapshots( browserIds: [...browserWorkspaceByIdForWorktree.keys()] }) const tabs: RuntimeMobileSessionSnapshotTab[] = [] + const emittedEditorFileIds = new Set() + const emittedEditorTabIds = new Set() for (const item of groupProjection.order) { if (item.type === 'terminal') { @@ -706,6 +721,8 @@ export function buildMobileSessionTabSnapshots( } else { tabs.push(buildMobileFileTab(state, file, item.tabId)) } + emittedEditorFileIds.add(file.id) + emittedEditorTabIds.add(item.tabId ?? item.id) } else if (item.type === 'browser') { const workspace = browserWorkspaceByIdForWorktree.get(item.id) if (!workspace) { @@ -715,7 +732,75 @@ export function buildMobileSessionTabSnapshots( } } + // Why: split-group projection can miss plain editor files during hydration. + // Publish the missing file so paired mobile/web clients still mirror it. + const fallbackEditorTabs: FallbackEditorTabTarget[] = [] + const openFilesForWorktree = openFileIndexes.byWorktreeAndId.get(worktreeId) + if (openFilesForWorktree) { + const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(state, worktreeId) + const unifiedEditorFileIds = new Set(unifiedEditorTabs.map((tab) => tab.entityId)) + for (const unifiedTab of unifiedEditorTabs) { + if (emittedEditorTabIds.has(unifiedTab.id)) { + continue + } + const file = openFilesForWorktree.get(unifiedTab.entityId) + if (!file) { + continue + } + const markdown = buildMobileMarkdownTab( + state, + openFileIndexes.byWorktreeAndId, + editorDraftVersionByFileId, + file, + unifiedTab.id + ) + const fallbackTab = markdown ?? buildMobileFileTab(state, file, unifiedTab.id) + tabs.push(fallbackTab) + fallbackEditorTabs.push({ + tabId: fallbackTab.id, + groupId: unifiedTab.groupId + }) + emittedEditorTabIds.add(unifiedTab.id) + } + for (const file of openFilesForWorktree.values()) { + if (emittedEditorFileIds.has(file.id)) { + continue + } + if (unifiedEditorFileIds.has(file.id)) { + emittedEditorFileIds.add(file.id) + continue + } + const markdown = buildMobileMarkdownTab( + state, + openFileIndexes.byWorktreeAndId, + editorDraftVersionByFileId, + file + ) + const fallbackTab = markdown ?? buildMobileFileTab(state, file) + tabs.push(fallbackTab) + fallbackEditorTabs.push({ + tabId: fallbackTab.id, + groupId: null + }) + emittedEditorFileIds.add(file.id) + } + } + const active = tabs.find((tab) => tab.isActive) ?? null + const tabGroups = appendFallbackEditorTabsToGroups( + groupProjection.tabGroups, + state.groupsByWorktree[worktreeId] ?? [], + activeGroupId, + fallbackEditorTabs, + active?.id ?? null + ) + const tabGroupLayout = + tabGroups && tabGroups.length > 0 + ? pruneTabGroupLayout( + (state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE)[worktreeId], + new Set(tabGroups.map((group) => group.id)) + ) + : groupProjection.tabGroupLayout snapshots.push({ worktree: worktreeId, publicationEpoch: mobileSessionPublicationEpoch, @@ -723,10 +808,8 @@ export function buildMobileSessionTabSnapshots( activeGroupId, activeTabId: active?.id ?? null, activeTabType: active?.type ?? null, - ...(groupProjection.tabGroups && groupProjection.tabGroups.length > 0 - ? { tabGroups: groupProjection.tabGroups } - : {}), - ...(groupProjection.tabGroupLayout ? { tabGroupLayout: groupProjection.tabGroupLayout } : {}), + ...(tabGroups && tabGroups.length > 0 ? { tabGroups } : {}), + ...(tabGroupLayout ? { tabGroupLayout } : {}), tabs }) } @@ -734,6 +817,125 @@ export function buildMobileSessionTabSnapshots( return snapshots } +function isEditorSurfaceTab(tab: Pick): boolean { + // Why: mobile file snapshots can faithfully mirror ordinary edit/diff files; + // conflict review and check-details tabs require metadata this contract lacks. + return tab.contentType === 'editor' || tab.contentType === 'diff' +} + +function getEditorUnifiedTabsForWorktree( + state: Pick, + worktreeId: string +): Tab[] { + return (state.unifiedTabsByWorktree[worktreeId] ?? []).filter(isEditorSurfaceTab) +} + +function applyUnifiedEditorTabIdsToLegacyOrder( + order: readonly VisibleTabRef[], + state: Pick, + worktreeId: string +): VisibleTabRef[] { + const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(state, worktreeId) + if (unifiedEditorTabs.length === 0) { + return [...order] + } + const firstUnifiedTabByFileId = new Map() + for (const tab of unifiedEditorTabs) { + if (!firstUnifiedTabByFileId.has(tab.entityId)) { + firstUnifiedTabByFileId.set(tab.entityId, tab.id) + } + } + return order.map((item) => { + if (item.type !== 'editor' || item.tabId) { + return item + } + const tabId = firstUnifiedTabByFileId.get(item.id) + return tabId ? { ...item, tabId } : item + }) +} + +function appendFallbackEditorTabsToGroups( + tabGroups: RuntimeMobileSessionTabGroup[] | undefined, + sourceGroups: readonly TabGroup[], + activeGroupId: string | null, + fallbackTabs: readonly FallbackEditorTabTarget[], + activeTabId: string | null +): RuntimeMobileSessionTabGroup[] | undefined { + if (fallbackTabs.length === 0) { + return tabGroups + } + const result = [...(tabGroups ?? [])] + const sourceGroupsById = new Map(sourceGroups.map((group) => [group.id, group])) + const groupIndexById = new Map(result.map((group, index) => [group.id, index])) + const firstTargetGroupId = + result[0]?.id ?? + (activeGroupId && sourceGroupsById.has(activeGroupId) ? activeGroupId : null) ?? + sourceGroups[0]?.id ?? + null + const fallbackTabIdSet = new Set(fallbackTabs.map((tab) => tab.tabId)) + + for (const fallback of fallbackTabs) { + const targetGroupId = + fallback.groupId ?? + (activeGroupId && (groupIndexById.has(activeGroupId) || sourceGroupsById.has(activeGroupId)) + ? activeGroupId + : firstTargetGroupId) + if (!targetGroupId) { + continue + } + let targetIndex = groupIndexById.get(targetGroupId) + if (targetIndex === undefined) { + const sourceGroup = sourceGroupsById.get(targetGroupId) + const group: RuntimeMobileSessionTabGroup = { + id: targetGroupId, + activeTabId: sourceGroup?.activeTabId ?? null, + tabOrder: [], + recentTabIds: sourceGroup?.recentTabIds ?? [] + } + targetIndex = result.length + groupIndexById.set(targetGroupId, targetIndex) + result.push(group) + } + const group = result[targetIndex]! + if (!group.tabOrder.includes(fallback.tabId)) { + result[targetIndex] = { + ...group, + tabOrder: [...group.tabOrder, fallback.tabId] + } + } + } + + if (result.length === 0) { + return tabGroups + } + + const activeFallbackTabId = activeTabId && fallbackTabIdSet.has(activeTabId) ? activeTabId : null + + return result.map((group) => { + const tabOrder = [...group.tabOrder] + const tabOrderSet = new Set(tabOrder) + const activeFallbackTabIdForGroup = + activeFallbackTabId && tabOrderSet.has(activeFallbackTabId) ? activeFallbackTabId : null + const activeTabIdForGroup = + activeFallbackTabIdForGroup ?? + (group.activeTabId && tabOrderSet.has(group.activeTabId) ? group.activeTabId : null) + const recentTabIds = (group.recentTabIds ?? []).filter((tabId) => tabOrderSet.has(tabId)) + if ( + activeFallbackTabId && + tabOrderSet.has(activeFallbackTabId) && + !recentTabIds.includes(activeFallbackTabId) + ) { + recentTabIds.push(activeFallbackTabId) + } + return { + ...group, + activeTabId: activeTabIdForGroup, + tabOrder, + recentTabIds + } + }) +} + function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null } @@ -859,9 +1061,13 @@ function buildMobileSessionGroupProjection( const groups = state.groupsByWorktree[worktreeId] ?? [] if (groups.length === 0) { return { - order: getActiveTabNavOrder(state, worktreeId, { - editorIds: ids.editorIds - }) + order: applyUnifiedEditorTabIdsToLegacyOrder( + getActiveTabNavOrder(state, worktreeId, { + editorIds: ids.editorIds + }), + state, + worktreeId + ) } } @@ -1137,7 +1343,7 @@ function buildMobileMarkdownTab( isDirty: file.isDirty || sourceFile.isDirty, isActive: unifiedTabId ? isUnifiedTabActiveInActiveGroup(state, file.worktreeId, unifiedTabId) - : state.activeFileId === file.id, + : isFileActiveEditorSurface(state, file), sourceFileId: sourceFile.id, sourceFilePath: sourceFile.filePath, sourceRelativePath: sourceFile.relativePath, @@ -1165,10 +1371,24 @@ function buildMobileFileTab( isDirty: file.isDirty, isActive: unifiedTabId ? isUnifiedTabActiveInActiveGroup(state, file.worktreeId, unifiedTabId) - : state.activeFileId === file.id + : isFileActiveEditorSurface(state, file) } } +function isFileActiveEditorSurface( + state: Pick< + AppState, + 'activeFileId' | 'activeFileIdByWorktree' | 'activeTabType' | 'activeTabTypeByWorktree' + >, + file: Pick +): boolean { + const activeType = state.activeTabTypeByWorktree?.[file.worktreeId] ?? state.activeTabType + return ( + activeType === 'editor' && + (state.activeFileIdByWorktree?.[file.worktreeId] ?? state.activeFileId) === file.id + ) +} + function isMobileFileDiffSource( diffSource: AppState['openFiles'][number]['diffSource'] ): diffSource is 'staged' | 'unstaged' { From e3ca72b2b59f3fcf2876cdcc3e77b52308d70f77 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:22:01 -0700 Subject: [PATCH 003/163] Fix Jira credentials clearing on forbidden task reads (#5757) Co-authored-by: Orca --- src/main/jira/client.test.ts | 7 +++++++ src/main/jira/client.ts | 4 +++- src/renderer/src/store/slices/jira.test.ts | 12 ++++++++++++ src/renderer/src/store/slices/jira.ts | 4 +++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index ab949441e2a..713311b8acc 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -364,6 +364,13 @@ describe('Jira client credential storage', () => { }) }) + it('does not treat Jira permission failures as credential revocation', async () => { + const jira = await loadClientModule() + + expect(jira.isAuthError(new jira.JiraApiError('Unauthorized', 401))).toBe(true) + expect(jira.isAuthError(new jira.JiraApiError('Forbidden', 403))).toBe(false) + }) + it('bridges proxy environment settings before Jira connect requests', async () => { netFetchMock.mockResolvedValueOnce( new Response( diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index a0e705290d2..d56e32d3501 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -574,5 +574,7 @@ export function clearToken(siteId: string): void { } export function isAuthError(error: unknown): boolean { - return error instanceof JiraApiError && (error.status === 401 || error.status === 403) + // Why: Jira returns 403 for project/API permission gaps even when /myself + // succeeds, so only 401 means the saved credential itself is invalid. + return error instanceof JiraApiError && error.status === 401 } diff --git a/src/renderer/src/store/slices/jira.test.ts b/src/renderer/src/store/slices/jira.test.ts index 565467e0542..ebe427f443b 100644 --- a/src/renderer/src/store/slices/jira.test.ts +++ b/src/renderer/src/store/slices/jira.test.ts @@ -390,4 +390,16 @@ describe('createJiraSlice credential errors', () => { expect(store.getState().jiraStatus.credentialError).toBeUndefined() }) }) + + it('keeps Jira connected when an issue read hits endpoint-level forbidden access', async () => { + const store = createTestStore() + store.setState({ + jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } + }) + jiraListIssues.mockRejectedValueOnce(new Error('Forbidden')) + + await expect(store.getState().listJiraIssues('assigned', 30)).resolves.toEqual([]) + + expect(store.getState().jiraStatus.connected).toBe(true) + }) }) diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index 9892ee8e79b..1498f312edd 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -55,7 +55,9 @@ function evictStaleEntries( function looksLikeAuthError(error: unknown): boolean { const msg = error instanceof Error ? error.message : String(error) - return /authenticat|unauthorized|forbidden|401|403/i.test(msg) + // Why: Jira 403 commonly means endpoint/project access is denied while the + // saved token is still valid; do not flip Settings back to disconnected. + return /authenticat|unauthorized|401/i.test(msg) } type InflightJiraReadRequest = { From bbacb694cd53417e9c5d07bcde78b8b87133e6d4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:25:55 -0700 Subject: [PATCH 004/163] Recognize Claude agents in sidebar (#5758) Co-authored-by: Orca --- .../worktree-title-derived-agent-rows.test.ts | 33 +++++++++++-------- .../worktree-title-derived-agent-rows.ts | 16 ++++++--- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index 86e1eb67ab6..becaafb3e4c 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -138,20 +138,27 @@ describe('buildTitleDerivedAgentRows', () => { expect(rows).toHaveLength(0) }) - it('does not add title-derived rows for the Claude agents management screen', () => { - const rows = buildWorktreeAgentRows({ - tabs: [makeTab('tab-1')], - entries: [], - retained: [], - runtimePaneTitlesByTabId: { - 'tab-1': { 1: 'claude agents' } - }, - ptyIdsByTabId: { 'tab-1': ['pty-claude-agents'] }, - terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, - now: 2000 - }) + it('adds an idle Claude row for the Claude agents surface', () => { + for (const title of [ + 'claude agents', + String.raw`C:\Users\dev\AppData\Roaming\npm\claude.cmd agents` + ]) { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1')], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { + 'tab-1': { 1: title } + }, + ptyIdsByTabId: { 'tab-1': ['pty-claude-agents'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) - expect(rows).toHaveLength(0) + expect(rows.map((row) => [row.agentType, row.state, row.entry.lastAssistantMessage])).toEqual( + [['claude', 'idle', 'Idle']] + ) + } }) it('does not turn generic Codex-launched task titles into Claude Code rows', () => { diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 3e41fb49d7d..5b37123b0e8 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -1,5 +1,9 @@ import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' -import { detectAgentStatusFromTitle, getAgentLabel } from '@/lib/agent-status' +import { + detectAgentStatusFromTitle, + getAgentLabel, + isClaudeManagementTitle +} from '@/lib/agent-status' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { type AgentStatusEntry, @@ -117,8 +121,12 @@ function buildTitleDerivedAgentRow(args: { now: number runtimeAgentOrchestrationByPaneKey?: Record }): DashboardAgentRow | null { - const status = detectAgentStatusFromTitle(args.title) - const label = getAgentLabel(args.title) + const isClaudeAgentsTitle = isClaudeManagementTitle(args.title) + // Why: `claude agents` is a live Claude Code Agent Teams surface, but the + // shared detector keeps it neutral so runtime liveness probes do not treat + // the management/list screen as active work. + const status = isClaudeAgentsTitle ? 'idle' : detectAgentStatusFromTitle(args.title) + const label = isClaudeAgentsTitle ? 'Claude Code' : getAgentLabel(args.title) if (!status || !label) { return null } @@ -127,7 +135,7 @@ function buildTitleDerivedAgentRow(args: { } const paneKey = makePaneKey(args.tab.id, args.leafId) const orchestration = args.runtimeAgentOrchestrationByPaneKey?.[paneKey] - const agentType = resolveTitleDerivedAgentType(args.title, label) + const agentType = isClaudeAgentsTitle ? 'claude' : resolveTitleDerivedAgentType(args.title, label) if (!agentType) { return null } From 4c5c2f4df3904018fcb02f5261cef457b86df81f Mon Sep 17 00:00:00 2001 From: Goo <120996395+39hn@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:26:12 +0900 Subject: [PATCH 005/163] fix(i18n): correct Korean mistranslations and particle spacing (#5761) --- .../scripts/locale-cjk-latin-spaced-terms.mjs | 50 ++ config/scripts/locale-ko-value-overrides.mjs | 4 + ...cale-translation-policy-ko-round5.test.mjs | 49 ++ config/scripts/locale-translation-policy.mjs | 59 +- src/renderer/src/i18n/locales/en.json | 11 +- src/renderer/src/i18n/locales/es.json | 13 +- src/renderer/src/i18n/locales/ja.json | 10 +- src/renderer/src/i18n/locales/ko.json | 604 +++++++++--------- src/renderer/src/i18n/locales/zh.json | 10 +- 9 files changed, 455 insertions(+), 355 deletions(-) create mode 100644 config/scripts/locale-cjk-latin-spaced-terms.mjs diff --git a/config/scripts/locale-cjk-latin-spaced-terms.mjs b/config/scripts/locale-cjk-latin-spaced-terms.mjs new file mode 100644 index 00000000000..85f460a4980 --- /dev/null +++ b/config/scripts/locale-cjk-latin-spaced-terms.mjs @@ -0,0 +1,50 @@ +// Latin workflow/brand terms that get a space inserted around them when CJK machine +// translation glues them to native text (see applyCjkLatinTermSpacing). +export const CJK_LATIN_SPACED_TERMS = [ + 'Issues', + 'Issue', + 'Terminal', + 'Terminals', + 'terminal', + 'terminals', + 'Agents', + 'Agent', + 'agents', + 'agent', + 'Markdown', + 'markdown', + 'Repos', + 'Repo', + 'repos', + 'repo', + 'Commits', + 'Commit', + 'commits', + 'commit', + 'Linear', + 'GitHub', + 'GitLab', + 'Jira', + 'Claude', + 'Claude Code', + 'Codex', + 'Gemini', + 'Kimi', + 'OpenCode', + 'Orca', + 'Cursor', + 'Bitbucket', + 'Tailscale', + 'Kagi', + 'SSH', + 'WSL', + 'PR', + 'MR', + 'REST', + 'HEAD', + 'Bash', + 'PowerShell', + 'Git AI Author', + 'Token', + 'token' +] diff --git a/config/scripts/locale-ko-value-overrides.mjs b/config/scripts/locale-ko-value-overrides.mjs index 10587fca996..158e4519674 100644 --- a/config/scripts/locale-ko-value-overrides.mjs +++ b/config/scripts/locale-ko-value-overrides.mjs @@ -87,6 +87,10 @@ export const KO_VALUE_OVERRIDES = { Start: '시작', Work: '작업', Show: '표시', + Hide: '숨기기', + Import: '가져오기', + import: '가져오기', + 'Import…': '가져오기…', Pass: '통과', Maintain: '유지', Approve: '승인', diff --git a/config/scripts/locale-translation-policy-ko-round5.test.mjs b/config/scripts/locale-translation-policy-ko-round5.test.mjs index ad74a4f18ac..4e7359e54ef 100644 --- a/config/scripts/locale-translation-policy-ko-round5.test.mjs +++ b/config/scripts/locale-translation-policy-ko-round5.test.mjs @@ -134,4 +134,53 @@ describe('locale-translation-policy ko round 5', () => { }) ).toBe('로컬 프로젝트, Git repo 또는 repos가 많은 폴더') }) + + it('re-glues Korean particles after Latin terms without gluing content words', () => { + // A wrong space before the 에 particle is re-glued onto the Latin term. + expect( + repairTranslatedValue({ + key: 'auto.components.StarNagCard.cf82170065', + enValue: 'Could not star the repo. Make sure', + localeValue: 'repo 에 스타를 표시할 수 없습니다. 다음을 확인하세요', + locale: 'ko' + }) + ).toBe('repo에 스타를 표시할 수 없습니다. 다음을 확인하세요') + expect( + repairTranslatedValue({ + key: 'auto.components.github.project.ProjectCell.4b5b871da8', + enValue: 'No labels in this repo.', + localeValue: '이 repo 에는 라벨이 없습니다.', + locale: 'ko' + }) + ).toBe('이 repo에는 라벨이 없습니다.') + expect( + repairTranslatedValue({ + key: 'auto.components.terminal.pane.CloseTerminalDialog.6b9a6975f8', + enValue: + 'The terminal still has a running process. If you close the terminal, the process will be killed.', + localeValue: + 'terminal 에는 여전히 실행 중인 프로세스가 있습니다. terminal을 닫으면 프로세스가 종료됩니다.', + locale: 'ko' + }) + ).toBe( + 'terminal에는 여전히 실행 중인 프로세스가 있습니다. terminal을 닫으면 프로세스가 종료됩니다.' + ) + // A correct space before a content word that merely starts with a particle syllable is kept. + expect( + repairTranslatedValue({ + key: 'auto.components.JiraIssueWorkspace.76513c7898', + enValue: 'Close Jira issue preview', + localeValue: 'Jira 이슈 미리보기 닫기', + locale: 'ko' + }) + ).toBe('Jira 이슈 미리보기 닫기') + expect( + repairTranslatedValue({ + key: 'auto.components.GitHubItemDialog.3ab6ac0fc8', + enValue: 'Preview and edit the selected GitHub issue or pull request.', + localeValue: '선택한 GitHub 이슈 또는 PR을 미리보기하고 편집합니다.', + locale: 'ko' + }) + ).toBe('선택한 GitHub 이슈 또는 PR을 미리보기하고 편집합니다.') + }) }) diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index fabcee1be07..c6fb247702a 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -1,3 +1,4 @@ +import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs' import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs' import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs' import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs' @@ -20,6 +21,7 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'Agents', 'Aider', 'Amp', + 'Android', 'Antigravity', 'Auggie', 'Autohand Code', @@ -35,6 +37,8 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'Droid', 'Devin', 'Gemini', + 'Git', + 'Git Bash', 'GitHub Copilot', 'GitLab', 'Goose', @@ -68,6 +72,7 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'Zed', 'agent', 'agents', + 'android', 'codex', 'commit', 'commits', @@ -188,6 +193,7 @@ export const BRAND_MISTRANSLATIONS = { Goose: ['거위'], Pi: ['파이'], 'GitHub Copilot': ['GitHub 코파일럿', '코파일럿'], + Git: ['힘내'], Discord: ['디스코드'], Linear: ['선형'], Agent: ['에이전트'], @@ -331,55 +337,6 @@ export const NATIVE_PICKER_LABELS = { es: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' } } -const CJK_LATIN_SPACED_TERMS = [ - 'Issues', - 'Issue', - 'Terminal', - 'Terminals', - 'terminal', - 'terminals', - 'Agents', - 'Agent', - 'agents', - 'agent', - 'Markdown', - 'markdown', - 'Repos', - 'Repo', - 'repos', - 'repo', - 'Commits', - 'Commit', - 'commits', - 'commit', - 'Linear', - 'GitHub', - 'GitLab', - 'Jira', - 'Claude', - 'Claude Code', - 'Codex', - 'Gemini', - 'Kimi', - 'OpenCode', - 'Orca', - 'Cursor', - 'Bitbucket', - 'Tailscale', - 'Kagi', - 'SSH', - 'WSL', - 'PR', - 'MR', - 'REST', - 'HEAD', - 'Bash', - 'PowerShell', - 'Git AI Author', - 'Token', - 'token' -] - const CJK_LATIN_SPACED_TERM_PATTERN = CJK_LATIN_SPACED_TERMS.join('|') export function isEnglishOnlyKey(key) { @@ -465,9 +422,11 @@ function applyCjkLatinTermSpacing(localeValue, locale) { '$1 $2' ) if (locale === 'ko') { + // Korean particles attach to the noun (no space) only when the particle is a complete token at a + // boundary — re-glue "Orca 에"/"PR 을"/"에서는" but keep "Jira 이슈"/"Orca 로고"/"agent 에뮬레이터". result = result.replace( new RegExp( - `(${CJK_LATIN_SPACED_TERM_PATTERN}) (가|이|은|는|을|를|와|과|의|로|으로|에서|에게|도|만|부터|까지)`, + `(${CJK_LATIN_SPACED_TERM_PATTERN}) ((?:에서|에게|에는|에선|으로|로서|로써|부터|까지|보다|처럼|은|는|이|가|을|를|와|과|의|에|로|도|만)+)(?=$|[\\s.,!?…·:;)\\]}"'」』])`, 'g' ), '$1$2' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0127265d942..b35a44e593c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3668,7 +3668,6 @@ "1a0eec0d35": "Status", "b5536d5a88": "Tasks", "8d62c68b35": "Notes", - "automation": "Automation", "2d74665a56": "Ports", "65a9820bd1": "Agent statuses", "219ebf1961": "Branch name" @@ -11391,7 +11390,15 @@ "openReviewExternally": "Open {{value0}} externally", "summary": "{{value0}} attached · {{value1}} with PR/MR · {{value2}} attention · {{value3}} pending · {{value4}} passing · {{value5}} no PR · {{value6}} unknown", "showDetails": "Show {{value0}} PR check details", - "hideDetails": "Hide {{value0}} PR check details" + "hideDetails": "Hide {{value0}} PR check details", + "reviewChecks": "Review checks", + "allChecksPassing": "all checks passing", + "oneWorktree": "1 worktree", + "worktreeCount": "{{value0}} worktrees", + "oneFailing": "1 failing", + "failingCount": "{{value0}} failing", + "onePending": "1 pending", + "pendingCount": "{{value0}} pending" }, "parentPrChecks": { "rowSummary": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 8da4fbcb5e7..8ecde6d4d33 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3656,8 +3656,7 @@ "automation": "Automation", "2d74665a56": "Puertos", "65a9820bd1": "Estados del agente", - "219ebf1961": "Nombre de rama", - "automation": "Automation" + "219ebf1961": "Nombre de rama" }, "SshDisconnectedDialog": { "ca4a7892af": "Conectando...", @@ -11391,7 +11390,15 @@ "openReviewExternally": "Abrir {{value0}} externamente", "summary": "{{value0}} adjuntos · {{value1}} con PR/MR · {{value2}} requieren atención · {{value3}} pendientes · {{value4}} correctos · {{value5}} sin PR · {{value6}} desconocidos", "showDetails": "Mostrar detalles de comprobaciones de PR de {{value0}}", - "hideDetails": "Ocultar detalles de comprobaciones de PR de {{value0}}" + "hideDetails": "Ocultar detalles de comprobaciones de PR de {{value0}}", + "reviewChecks": "Review checks", + "allChecksPassing": "all checks passing", + "oneWorktree": "1 worktree", + "worktreeCount": "{{value0}} worktrees", + "oneFailing": "1 failing", + "failingCount": "{{value0}} failing", + "onePending": "1 pending", + "pendingCount": "{{value0}} pending" }, "parentPrChecks": { "rowSummary": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7118aa3d9f9..792560bea01 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11390,7 +11390,15 @@ "openReviewExternally": "{{value0}} を外部で開く", "summary": "{{value0}} 件添付 · PR/MR あり {{value1}} 件 · 要対応 {{value2}} 件 · 保留中 {{value3}} 件 · 成功 {{value4}} 件 · PR なし {{value5}} 件 · 不明 {{value6}} 件", "showDetails": "{{value0}} の PR チェック詳細を表示", - "hideDetails": "{{value0}} の PR チェック詳細を非表示" + "hideDetails": "{{value0}} の PR チェック詳細を非表示", + "reviewChecks": "Review checks", + "allChecksPassing": "all checks passing", + "oneWorktree": "1 worktree", + "worktreeCount": "{{value0}} worktrees", + "oneFailing": "1 failing", + "failingCount": "{{value0}} failing", + "onePending": "1 pending", + "pendingCount": "{{value0}} pending" }, "parentPrChecks": { "rowSummary": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 55751a4827e..ba9135bcd9f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -224,7 +224,7 @@ "90d129b48b": "폴더가 추가됨", "8bb3ad7935": "프로젝트가 추가됨", "a8e4b3af5b": "프로젝트가 이미 추가되었습니다.", - "6d3318e813": "저장소를 가져오지 못했습니다." + "6d3318e813": "repos를 가져오지 못했습니다." }, "settings": { "e12dab333b": "서버를 전환하지 못했습니다.", @@ -516,9 +516,9 @@ "a9ff236145": "일부 첨부파일을 업로드할 수 없습니다.", "3db83fc58a": "첨부 파일에 사용할 수 있는 원격 프로젝트 경로가 없습니다.", "ba6cb77082": "프로젝트에 연결하지 못했습니다.", - "chooseOrAddProjectBeforeWorkspace": "작업 영역을 만들기 전에 프로젝트를 선택하거나 추가하세요.", - "folderWorkspaceCreateFailedTitle": "폴더 작업 영역을 만들지 못했습니다", - "folderWorkspaceCreateFailedMessage": "폴더 작업 영역을 만들 수 없습니다. 위의 오류 세부 정보를 확인한 뒤 다시 시도하세요." + "chooseOrAddProjectBeforeWorkspace": "워크스페이스을 만들기 전에 프로젝트를 선택하거나 추가하세요.", + "folderWorkspaceCreateFailedTitle": "폴더 워크스페이스을 만들지 못했습니다", + "folderWorkspaceCreateFailedMessage": "폴더 워크스페이스을 만들 수 없습니다. 위의 오류 세부 정보를 확인한 뒤 다시 시도하세요." }, "useGlobalFileDrop": { "38c9f034ff": "드롭한 파일을 업로드하지 못했습니다.", @@ -561,7 +561,7 @@ "d72a58b5b9": "통계 및 사용량", "dcd0d9b74f": "일반 작업에 대한 키보드 단축키입니다.", "94295ebfb3": "단축키", - "7682607591": "agent 및 terminal이벤트에 대한 기본 데스크톱 알림입니다.", + "7682607591": "agent 및 terminal 이벤트에 대한 기본 데스크톱 알림입니다.", "2eece16ad1": "알림", "1f452cbd4c": "선택 및 편집 동작.", "0c6ee88a5f": "입력 및 편집", @@ -569,7 +569,7 @@ "93d88d20bf": "외관", "2d0659f6f0": "전역 terminal, 브라우저 및 markdown 탭.", "65b19f5bde": "플로팅 워크스페이스", - "3d65d3f1b9": "Orca 및 코딩 agents 에 대한 모바일 에뮬레이터 지원을 구성합니다.", + "3d65d3f1b9": "Orca 및 코딩 agents에 대한 모바일 에뮬레이터 지원을 구성합니다.", "1e761cff2b": "모바일 에뮬레이터", "e815fd01bd": "홈페이지, 링크 라우팅 및 세션 쿠키.", "8c197f74a1": "브라우저", @@ -688,7 +688,7 @@ "e463ec935f": "검사 재실행 요청됨", "ddafe851e1": "확인 재실행이 요청되었습니다.", "0bbdc673c1": "확인을 새로고침하지 못했습니다.", - "e7007aa1d8": "저장소 경로가 없으면 검사를 새로 고칠 수 없습니다.", + "e7007aa1d8": "repos 경로가 없으면 검사를 새로 고칠 수 없습니다.", "675bc0d638": "취소", "a18f669c7a": "{{value0}} {{value1}} 반응{{value2}}", "53fe19aefc": "GitHub 병합 대화상자 열기", @@ -718,7 +718,7 @@ "bca8eb39ac": "댓글에 답장하기", "68cb993d61": "해결됨", "10f4ff5be8": "답글이 게시되었습니다.", - "745c9089ec": "저장소 경로가 없으면 응답할 수 없습니다.", + "745c9089ec": "repos 경로가 없으면 응답할 수 없습니다.", "58c73cb0d8": "설명을 업데이트하지 못했습니다.", "5221548274": "설명이 업데이트되었습니다.", "06c06e58ba": "아래에 더 많은 줄 표시", @@ -843,7 +843,7 @@ "f7cb495a12": "{{value0}}을(를) 다시 시도했습니다.", "98718490e4": "MR 제목이 필요합니다.", "d600c2619a": "로그 로드 중", - "028bde664e": "숨다", + "028bde664e": "숨기기", "2f9b27f838": "작업 로그", "032ae1312b": "GitLab에서 작업 열기", "fa3e042203": "다시 시도", @@ -901,7 +901,7 @@ "6ca6ff404e": "Orca", "520304a067": "Orca 로고", "ce44fad849": "종속성 누락", - "c1cf168479": "숨다", + "c1cf168479": "숨기기", "00cee697c1": "GitHub 계정을 연결하려면 terminal에서 \"gh auth login\"을 실행하세요.", "9f96d018b7": "GitHub CLI가 인증되지 않았습니다.", "73e1ad4282": "Orca는 GitHub CLI(gh)를 사용하여 PR, 이슈 및 검사를 표시합니다.", @@ -914,7 +914,7 @@ "0d0ace8861": "GitHub에서 별표 주기", "ec43b38ba7": "GitHub에 별표 표시됨", "157bb5ecbb": "GitHub 열기", - "preflightDismiss": "Dismiss" + "preflightDismiss": "닫기" }, "LinearIssueMarkdownDescriptionEditor": { "d9c47069ef": "Markdown", @@ -1039,7 +1039,7 @@ "92e34f0311": "로컬 설정", "326a578923": "orca.yaml + 로컬", "2132b670da": "둘 다", - "0e587e31fb": "YAML", + "0e587e31fb": "yaml", "ac3748dcda": "이름 또는 '다음에서 만들기'", "f660aa1454": "연결 중", "7711ad5122": "로컬 설정 명령", @@ -1052,7 +1052,7 @@ "connectingSsh": "Connecting SSH...", "sshAuthenticationFailed": "SSH authentication failed", "preparingSshConnection": "Preparing SSH connection...", - "connected": "Connected", + "connected": "연결됨", "reconnectingSsh": "Reconnecting SSH...", "sshReconnectionFailed": "SSH reconnection failed", "notConnected": "Not connected", @@ -1069,7 +1069,7 @@ "setupKindFolder": "Folder", "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", "importingHostSetup": "Importing...", - "importHostSetup": "Import" + "importHostSetup": "가져오기" }, "NewWorkspaceComposerModal": { "fa90f739a5": "워크스페이스를 생성하기 전에 프로젝트, 워크스페이스 이름, agent를 선택하세요." @@ -1135,7 +1135,7 @@ "18f2af42ac": "검사 재실행 요청됨", "5963a6a852": "확인 재실행이 요청되었습니다.", "246b2c6456": "확인을 새로고침하지 못했습니다.", - "c057f2fcb0": "저장소 경로가 없으면 검사를 새로 고칠 수 없습니다.", + "c057f2fcb0": "repos 경로가 없으면 검사를 새로 고칠 수 없습니다.", "6591b1fa82": "취소", "42c36d9166": "{{value0}} {{value1}} 반응{{value2}}", "7df8d5fc60": "GitHub 병합 대화상자 열기", @@ -1166,7 +1166,7 @@ "d6c6679de7": "댓글에 답장하기", "76b2a0ac5b": "해결됨", "11505c7a71": "답글이 게시되었습니다.", - "6885c619e7": "저장소 경로가 없으면 응답할 수 없습니다.", + "6885c619e7": "repos 경로가 없으면 응답할 수 없습니다.", "d94810f652": "설명을 업데이트하지 못했습니다.", "9b4190dc98": "설명이 업데이트되었습니다.", "51ed0cf38b": "아래에 더 많은 줄 표시", @@ -1253,7 +1253,7 @@ "ec31e058f7": "파일로 이동", "73b44e7bde": "설치 명령 복사", "1cf8561ab4": "빠른 gitignore 인식 목록을 활성화하려면 리모콘에서 다음을 수행하세요.", - "5d80dc39bb": "립그렙", + "5d80dc39bb": "ripgrep", "2ca749c15d": "설치", "4725b0e931": "Quick Open 스캔이 너무 큼(", "b227d88520": "{{value0}} 파일을 찾았습니다.", @@ -1266,7 +1266,7 @@ "StarNagCard": { "92b0f9d921": "인증된 후 다시 시도하세요.", "cd8c34aac1": "gh", - "cf82170065": "repo 에 스타를 표시할 수 없습니다. 다음을 확인하세요", + "cf82170065": "repo에 스타를 표시할 수 없습니다. 다음을 확인하세요", "30c36231c1": "Orca는 오픈 소스입니다. 오늘 도움이 되었다면 GitHub 스타가 다른 개발자가 Orca를 찾는 데 도움이 됩니다.", "b5e685e4d9": "닫기", "5f6df21046": "Orca를 즐기고 있나요?", @@ -1727,7 +1727,7 @@ "df636f5886": "설정되어 있는지 확인하세요(PowerShell)." }, "ProjectCell": { - "4b5b871da8": "이 repo 에는 라벨이 없습니다.", + "4b5b871da8": "이 repo에는 라벨이 없습니다.", "2219e945ef": "로드 중…", "54cac64427": "행에는 repo 슬러그가 없습니다.", "8ae56a88a6": "라벨", @@ -1735,7 +1735,7 @@ "ebde486e3c": "지우기", "191905e20e": "현재 및 향후", "e17bb96881": "완전한", - "943b3dadc9": "이 repo 에는 이슈 유형이 없습니다.", + "943b3dadc9": "이 repo에는 이슈 유형이 없습니다.", "c7b059cf07": "이슈 유형", "c5f949e489": "이슈", "8d669084f6": "제한된", @@ -1802,7 +1802,7 @@ "c5bc7ec007": "보기 필터: {{value0}}", "840c268665": "repo 추가", "dffa899f36": "취소", - "7037c8f5f1": "Orca에 없는 저장소", + "7037c8f5f1": "Orca에 없는 Repos", "512fc171d6": "시작하려면 프로젝트를 선택하세요.", "71fb69926c": "새로고침", "a8fa0d2bf5": "새로고침 중", @@ -1924,7 +1924,7 @@ "1f2f28a4de": "검색 API", "c377a4f06a": "검색", "c392c749a6": "REST API", - "bb227706a6": "나머지", + "bb227706a6": "REST", "budget_scope_prefix": "Budget scope" } } @@ -1944,7 +1944,7 @@ "f6a7b8c9d0": "Close issue", "b1c2d3e4f5": "Reopen issue", "0a73f59e85": "Send comment", - "bf43425540": "Comment" + "bf43425540": "댓글" }, "GitHubWorkItemAssigneePopoverContent": { "cddd9b04a7": "Loading assignees", @@ -2118,7 +2118,7 @@ "f68d538c63": "이 정리 세트에는 워크스페이스가 없습니다.", "4719327c9c": "모든 정리 제안은 무시됩니다.", "a19040cd67": "선택한 repos와 일치하는 비활성 워크스페이스가 없습니다.", - "97c772c4fe": "확인된 저장소에 비활성 워크스페이스가 없습니다.", + "97c772c4fe": "확인된 repos에 비활성 워크스페이스가 없습니다.", "d3eef9463d": "삭제할 비활성 워크스페이스가 없습니다.", "aaee139eab": "무시된 제안 복원", "06cf78521e": "{{value0}}에서 모두 선택", @@ -2237,7 +2237,7 @@ "CloseTerminalDialog": { "ebd2fa844d": "닫기", "1d1a7a9c1f": "취소", - "6b9a6975f8": "terminal 에는 여전히 실행 중인 프로세스가 있습니다. terminal을 닫으면 프로세스가 종료됩니다.", + "6b9a6975f8": "terminal에는 여전히 실행 중인 프로세스가 있습니다. terminal을 닫으면 프로세스가 종료됩니다.", "78b79d854d": "Terminal을 닫으시겠습니까?", "stop_agent_title": "Stop this agent?", "stop_command_title": "Stop running command?", @@ -2249,18 +2249,18 @@ }, "MobileDriverOverlay": { "c6460cf584": "회수", - "c8f2e1a4b9": "이 터미널 다시 가져오기", + "c8f2e1a4b9": "이 terminal 다시 가져오기", "c44659e09f": "휴대폰 조작 중", "7cffad954c": "접기", "3eed73394f": "키보드가 일시중지되었습니다.", "faa367dc74": "휴대폰이 휴대폰 크기로 남겨 둠", - "54f7d6f69d": "모든 터미널 다시 가져오기", - "b3d8e1f42a": "이 터미널 복원", - "e8c4f2a91b": "모든 터미널 복원", + "54f7d6f69d": "모든 terminals 다시 가져오기", + "b3d8e1f42a": "이 terminal 복원", + "e8c4f2a91b": "모든 terminals 복원", "f2a8b9c1d3": "휴대폰에서", "c7e4a2b8f1": "휴대폰이 제어 중", - "d9f3c6e2a4": "데스크톱 키보드가 일시 중지되었습니다. 여기서 입력하려면 이 터미널을 다시 가져오거나, 휴대폰이 제어하는 모든 터미널을 다시 가져오거나, 계속 보려면 접으세요.", - "a6b1d8f3e2": "휴대폰 세션이 종료되었습니다. 이 터미널 또는 휴대폰이 휴대폰 크기로 남긴 모든 터미널을 데스크톱 크기로 복원하세요." + "d9f3c6e2a4": "데스크톱 키보드가 일시 중지되었습니다. 여기서 입력하려면 이 terminals을 다시 가져오거나, 휴대폰이 제어하는 모든 terminals을 다시 가져오거나, 계속 보려면 접으세요.", + "a6b1d8f3e2": "휴대폰 세션이 종료되었습니다. 이 terminals 또는 휴대폰이 휴대폰 크기로 남긴 모든 terminals을 데스크톱 크기로 복원하세요." }, "TerminalAgentSessionForkDialog": { "17fc841e59": "컨텍스트 복사", @@ -2475,9 +2475,9 @@ "d364f3c8d4": "새로운 Terminal", "7c1313d237": "새로운 Terminal:", "d1afac112b": "WSL", - "efb33546ff": "힘내 배쉬", + "efb33546ff": "Git Bash", "1a8af49530": "CMD 프롬프트", - "2148f65e04": "파워셸", + "2148f65e04": "PowerShell", "ab589350e5": "{{value0}}에 대한 실행 명령을 작성할 수 없습니다.", "7a9b4af2af": "탭을 왼쪽으로 스크롤", "232e075b07": "탭을 오른쪽으로 스크롤" @@ -2497,7 +2497,7 @@ "b775303755": "빠른 명령 실행: {{value0}}", "196593b6a9": "{{value0}} 제거", "15529ede69": "{{value0}} 편집", - "1d411fb6a5": "이 repo 에 대한 빠른 명령 저장", + "1d411fb6a5": "이 repo에 대한 빠른 명령 저장", "8f1e971966": "빠른 명령 추가", "3220e2da27": "이 빠른 명령은 저장된 목록에서 제거됩니다.", "e8e1a52edb": "'{{value0}}'을 삭제하시겠습니까?", @@ -2511,7 +2511,7 @@ }, "shell": { "icons": { - "d4ceaa227c": "힘내", + "d4ceaa227c": "Git", "e9b2e70613": "WSL" } }, @@ -2545,11 +2545,11 @@ "164c394bab": "open file", "bbaf4f85a4": "mobile emulator", "3784b83bd4": "emulator", - "a63847a742": "simulator", + "a63847a742": "시뮬레이터", "1baeb07c17": "ios simulator", "8a580f88cf": "iphone", "7ecdc5ef08": "ipad", - "14965cc123": "mobile" + "14965cc123": "모바일" } } } @@ -2661,8 +2661,8 @@ "95e4ff5b4b": "푸시 중", "63a2b965f6": "가져오는 중", "remote_server": "Remote Server", - "runtime_checking": "Checking", - "runtime_online": "Connected", + "runtime_checking": "확인 중", + "runtime_online": "연결됨", "runtime_unavailable": "Disconnected", "runtime_available": "Available", "runtime_connect_unavailable": "Remote host is not reachable", @@ -2721,7 +2721,7 @@ "972a1ff497": "Reset Codex limits?", "6d1042aa6f": "This uses one Codex rate-limit reset credit for the active account and resets any eligible usage windows immediately.", "f077f586db": "Don't ask again", - "c0e972d726": "Cancel" + "c0e972d726": "취소" }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -2764,7 +2764,7 @@ "a02d84d2d2": "워크스페이스를 스캔 중입니다. 이 페이지를 떠날 수 있습니다.", "be37293b10": "상태", "33aef3e9cc": "크기", - "81f14d9924": "저장소", + "81f14d9924": "Repos", "e4aebea158": "워크스페이스", "1d0f8300d1": "표시되는 삭제 가능한 워크스페이스 선택", "697d60c456": "보이는 선택 항목 지우기", @@ -2797,7 +2797,7 @@ "fb2069acb7": "리뷰", "b9b4a3a25d": "브랜치", "c432278ec7": "편집기 버퍼", - "0bc756efaf": "힘내 변경", + "0bc756efaf": "Git 변경", "e9528a89b3": "Terminals", "a8d9e0de79": "Agents", "d384a4ce9f": "결정 삭제", @@ -2863,7 +2863,7 @@ "a9a318b7a3": "새로 고침 실패 - 캐시된 데이터 표시", "7ad719c4bf": "제한됨", "e740f92596": "새로 고침 실패", - "8418ec448d": "{{value0}} 사용량을 새로 고칠 수 없습니다. 에이전트 세션은 여전히 로그인되어 있을 수 있습니다.", + "8418ec448d": "{{value0}} 사용량을 새로 고칠 수 없습니다. Agent 세션은 여전히 로그인되어 있을 수 있습니다.", "45198c7d95": "1 rate-limit reset available", "bce421cba3": "{{value0}} rate-limit resets available" }, @@ -3069,7 +3069,7 @@ "2d13e57f72": "OpenCode 활성화", "2f1ee2878b": "Codex 활성화", "0ea0cae435": "Claude 활성화", - "6c00c46815": "공급자가 로컬 agent로그를 스캔하고 결합된 토큰 원장을 구축할 수 있도록 합니다.", + "6c00c46815": "공급자가 로컬 agent 로그를 스캔하고 결합된 토큰 원장을 구축할 수 있도록 합니다.", "49405ccc8d": "토큰 추적 시작", "ca6bc5fded": "새로고침", "e06d1baf5c": "새로 고침 사용량 개요", @@ -3191,7 +3191,7 @@ "cb142070b4": "새로고침", "984405683f": "플러그인", "4d177feabd": "번들로 제공", - "aa59462502": "저장소", + "aa59462502": "Repos", "571c5818c1": "집", "0bc1379f4c": "모든 소스", "38e0951c3a": "Agent 스킬", @@ -3207,7 +3207,7 @@ "dc4c3328ee": "파일 공개", "9963dff6d3": "설명을 찾을 수 없습니다.", "995fde8337": "스킬 파일을 공개할 수 없습니다.", - "ab5b777350": "로컬 홈, 저장소, 번들, 플러그인 스킬 폴더를 확인했습니다.", + "ab5b777350": "로컬 홈, repos, 번들, 플러그인 스킬 폴더를 확인했습니다.", "08a321a984": "검색 또는 필터를 조정합니다.", "4acd6d68ec": "로컬 스킬을 찾을 수 없습니다.", "6a62a0168c": "일치하는 항목 없음", @@ -3226,7 +3226,7 @@ "AddRepoCreateStep": { "0ae45b8238": "내 프로젝트", "a8149a3a5a": "이름", - "11fd2a7db8": "Git 저장소", + "11fd2a7db8": "Git repos", "5e97f0c4b9": "프로젝트가 생성되었습니다.", "2c12db1511": "프로젝트가 이미 추가되었습니다.", "875dda0995": "서버 상위 경로를 입력하세요.", @@ -3243,7 +3243,7 @@ "ssh_parent_manual": "Enter an SSH parent path." }, "AddRepoNestedImportStep": { - "496f68cf8c": "저장소를 스캔하는 중입니다. 중지하려면 클릭하세요.", + "496f68cf8c": "repos를 스캔하는 중입니다. 중지하려면 클릭하세요.", "a32bef9516": "스캔 중지", "2f8298f3c3": "스캔 중지", "c157f31a95": "그룹으로 가져오기", @@ -3251,17 +3251,17 @@ "787412361a": "그룹 이름은 무엇입니까?", "5f857ba8e6": "~에", "4df0d08cc5": "발견됨", - "8db50afe1a": "폴더에서 저장소 가져오기", + "8db50afe1a": "폴더에서 repos 가져오기", "5b2e6fe3c8": "별도로 가져오기", - "cf9d382ca1": "수입", + "cf9d382ca1": "가져오기", "220dd32d83": "스캐닝...", - "fb33359f69": "이 폴더가 모노레포인가요?", + "fb33359f69": "이 폴더가 모노 repo 인가요?", "d75170194e": "모노레포이거나 함께 속한 프로젝트라면 그룹으로 가져오세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.", "39d51212cc": "그룹 이름", "aa0247680d": "아니요, 별도로 가져오기", "a0bc4d1f8e": "그룹으로 가져오기", - "8401a7a0d0": "저장소 1개", - "d4f1df62ef": "저장소 {{value0}}개", + "8401a7a0d0": "repos 1개", + "d4f1df62ef": "repos {{value0}}개", "b4263a2ac4": "{{value1}}에서 {{value0}}을(를) 찾았습니다.", "24eda6c8b2": "스캔 중... {{value0}}", "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", @@ -3274,7 +3274,7 @@ "0416bde073": "설정에 추가", "df6fbcf880": "구성된 SSH 대상이 없습니다.", "44637f43bd": "SSH 대상", - "80557be85a": "연결된 SSH 대상을 선택하고 Git 저장소 경로를 입력하세요.", + "80557be85a": "연결된 SSH 대상을 선택하고 Git repos 경로를 입력하세요.", "91b93a90a4": "원격 프로젝트 열기", "007651bdf9": "디렉터리로 이동한 후 선택을 클릭하여 선택합니다.", "dd3ff65486": "원격 파일 시스템 찾아보기", @@ -3282,7 +3282,7 @@ "35831a7312": "첨가...", "lockedDescription": "Enter the path to a Git repository on {{value0}}.", "lockedDisconnected": "{{value0}} is disconnected.", - "93e0221434": "Connect" + "93e0221434": "연결" }, "AddRepoServerStartStep": { "ae990c86a0": "옵션 추가로 돌아가기", @@ -3291,13 +3291,13 @@ "ac66a3ed2d": "서버 파일 시스템 찾아보기", "92d25420a0": "/home/user/project", "867692f505": "서버 경로", - "423b5d3d31": "선택한 런타임 서버에 이미 존재하는 Git 저장소 또는 폴더를 추가하십시오.", + "423b5d3d31": "선택한 런타임 서버에 이미 존재하는 Git repos 또는 폴더를 추가하십시오.", "3d0c035483": "오픈 서버 프로젝트", "438493f214": "또는 서버 경로를 수동으로 입력하세요.", "6b9958492a": "한 번에 많은 repos를 가져오고 싶으십니까? 상위 폴더로 이동합니다.", "d40d751517": "새 repo 또는 폴더", "a81ffa0a99": "서버에서 생성", - "a2ea37d549": "원격 Git 저장소", + "a2ea37d549": "원격 Git repos", "47759c9491": "URL에서 복제", "516187414c": "기존 프로젝트 또는 폴더", "0adf083af7": "서버 찾아보기", @@ -3308,7 +3308,7 @@ "AddRepoStartSteps": { "acf895cb42": "Orca를 시작하려면 프로젝트를 추가하세요.", "d13757911c": "프로젝트 추가", - "d301db1c9a": "저장소를 스캔하는 중입니다. 중지하려면 클릭하세요.", + "d301db1c9a": "repos를 스캔하는 중입니다. 중지하려면 클릭하세요.", "69ea7f8dc4": "스캔 중지", "9906cae183": "스캔 중지", "87596c1446": "다른 추가 방법" @@ -3319,10 +3319,10 @@ "AddRepoSteps": { "569326d9cc": "폴더 선택", "a93ef169b5": "서버 파일 시스템 찾아보기", - "2ce3f6edf8": "/경로/대상/목적지", + "2ce3f6edf8": "/path/to/destination", "04a4c4e84a": "클론 위치", "b698a4a29d": "https://github.com/user/repo.git", - "3d4acbe693": "힘내 URL", + "3d4acbe693": "Git URL", "5b2ea674b1": "Git URL을 입력하고 복제할 위치를 선택하세요.", "c05f88a31f": "URL에서 복제", "fe8e629fe3": "디렉터리로 이동한 후 선택을 클릭하여 선택합니다.", @@ -3393,7 +3393,7 @@ "NonGitFolderDialog": { "e52454b7f6": "폴더로 열기", "05b33a17a9": "취소", - "8fba4b8cbb": "이 폴더는 Git 저장소가 아닙니다. 편집기, terminal, 검색 기능은 제공되지만 Git 기반 기능은 사용할 수 없습니다.", + "8fba4b8cbb": "이 폴더는 Git repos가 아닙니다. 편집기, terminal, 검색 기능은 제공되지만 Git 기반 기능은 사용할 수 없습니다.", "c49fb13492": "원격 폴더를 추가하지 못했습니다." }, "OrcaYamlTrustDialog": { @@ -3404,7 +3404,7 @@ "531689199b": "항상 신뢰하세요", "bf800b7e04": ". 믿을 수 있는 경우에만 실행하세요.", "831f2cd9f0": "당신의 컴퓨터에서 실행", - "aa3ffb33fb": "이 저장소의", + "aa3ffb33fb": "이 repos의", "c55beddbf8": "마지막으로 승인한 이후 변경되었습니다. 실행하기 전에 재리뷰하세요.", "95bf974a1a": "{{value0}} 스크립트", "9e52effffd": "새로운 {{value0}} 스크립트", @@ -3868,9 +3868,9 @@ "8df295a78d": "워크스페이스 이름을 바꾸지 못했습니다." }, "WorktreeVisibilityDialog": { - "83a5ba8dd1": "비Orca 작업 트리", - "f1f71b9f02": "수입", - "759371df43": "숨다", + "83a5ba8dd1": "비 Orca 작업 트리", + "f1f71b9f02": "가져오기", + "759371df43": "숨기기", "25ddf19920": "{{value0}} 가져오기 가능", "8372e4bbd9": "{{value0}} 현재 표시됨", "5d02a5647f": "사이드바에서 숨겨짐", @@ -3883,7 +3883,7 @@ "actions": { "d72789705e": "빈 폴더에서 시작", "c709860596": "새 프로젝트 만들기", - "5f9ffac036": "원격 Git 저장소 복제", + "5f9ffac036": "원격 Git repos 복제", "7edb8ebe24": "URL에서 복제", "a6c20dca96": "SSH 대상에서 프로젝트 열기", "3d162cc76f": "원격 프로젝트", @@ -3959,7 +3959,7 @@ } }, "useAddRepoCloneFlow": { - "4d0013cc93": "저장소가 복제되었습니다.", + "4d0013cc93": "Repos가 복제되었습니다.", "0dc4d1b657": "복제 대상의 서버 경로를 입력합니다." }, "useAddRepoLocalFolderFlow": { @@ -3969,8 +3969,8 @@ }, "useAddRepoNestedImportFlow": { "680cac2c82": "{{value0}} 실패", - "cbfbc7a797": "일부 저장소를 가져올 수 없습니다.", - "1b33c5f090": "가져온 저장소가 없습니다." + "cbfbc7a797": "일부 repos를 가져올 수 없습니다.", + "1b33c5f090": "가져온 repos가 없습니다." }, "useSidebarProjectDrop": { "f34a286c0d": "드롭한 폴더를 추가할 수 없습니다.", @@ -4104,7 +4104,7 @@ "create": "워크스페이스 만들기", "sourceProject": "작업 소스", "chooseSourceProject": "작업 소스 선택", - "createFailed": "폴더 작업 영역을 만들지 못했습니다." + "createFailed": "폴더 워크스페이스을 만들지 못했습니다." }, "ProjectOrderManualDefaultNotice": { "a1f4c2d8e0": "수동 프로젝트 순서가 이제 기본값입니다", @@ -4132,7 +4132,7 @@ "3c4d5e6f7a": "Remove {{value0}}?", "4d5e6f7a8b": "This opens the Orca servers settings where you can remove this server.", "5e6f7a8b9c": "This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.", - "6f7a8b9c0d": "Cancel", + "6f7a8b9c0d": "취소", "7a8b9c0d1e": "Open settings", "8b9c0d1e2f": "Remove host" }, @@ -4141,8 +4141,8 @@ "2b3c4d5e6f": "This label is shown only on this computer. Leave it blank to use the default name.", "3c4d5e6f7a": "Display name", "4d5e6f7a8b": "Reset to default", - "5e6f7a8b9c": "Cancel", - "6f7a8b9c0d": "Save" + "5e6f7a8b9c": "취소", + "6f7a8b9c0d": "저장" }, "HostSectionHeaderMenu": { "5b8b4b6a01": "Update server required", @@ -4154,7 +4154,7 @@ "6b7c8d9e10": "Host actions", "8d1e2f3a4b": "Rename…", "63f36455cc": "Reconnect", - "59b553e2aa": "Disconnect", + "59b553e2aa": "연결 해제", "2d3e4f5a6b": "Check connection", "3c4d5e6f7a": "Manage host…", "6e7f8a9b0c": "Remove host…" @@ -4398,16 +4398,16 @@ "0f28e7b30c": "Orca가 앱 창에 표시되는 방식을 선택합니다.", "leftSidebarAppearance": { "title": "왼쪽 사이드바 모양", - "rowDescription": "왼쪽 사이드바를 터미널에 맞추거나 기본값을 유지하거나 은은한 색조를 적용합니다.", + "rowDescription": "왼쪽 사이드바를 terminal에 맞추거나 기본값을 유지하거나 은은한 색조를 적용합니다.", "default": "기본값", - "matchTerminal": "터미널에 맞춤", + "matchTerminal": "Terminal에 맞춤", "tinted": "색조 적용", "tintColor": "사이드바 색조", "tintColorDescription": "왼쪽 사이드바 표면에 섞을 색상입니다.", "tintOpacity": "색조 강도", "tintOpacityDescription": "사이드바에 색조를 얼마나 강하게 섞을지 조절합니다." }, - "workspaceCardLayoutGuidance": "작업 공간 사이드바 옵션 메뉴 > 카드 레이아웃 > 컴팩트를 사용하세요." + "workspaceCardLayoutGuidance": "워크스페이스 사이드바 옵션 메뉴 > 카드 레이아웃 > 컴팩트를 사용하세요." }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -4423,7 +4423,7 @@ "9241b59bf5": "사용", "a869d0edd8": "브랜치 이름 명령 템플릿", "e784ea62dc": "고급", - "d9b65054ef": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca라는 이름의 브랜치만 이름이 변경되며 푸시된 후에는 이름이 변경되지 않습니다.", + "d9b65054ef": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca 라는 이름의 브랜치만 이름이 변경되며 푸시된 후에는 이름이 변경되지 않습니다.", "12ea4a408d": "agent가 새 워크스페이스에서 작업을 시작하면 Orca는 자동 생성된 브랜치의 이름을 바꿉니다(예:", "ef787db0e3": "브랜치 자동 이름 바꾸기", "6a051586d2": "agent가 시작되면 작업을 기반으로 자동 생성된 브랜치의 이름을 바꿉니다.", @@ -4461,7 +4461,7 @@ "a5c16712c1": "여러 개의 원격이 감지되었습니다. 원격 이름을 입력하세요(예:", "9a14ec7400": "아래에서 기본 브랜치를 선택하세요.", "086ce7f369": "다음 기본 브랜치({{value0}})", - "2f3cda96f5": "이 repo 에 고정됨", + "2f3cda96f5": "이 repo에 고정됨", "ee110e1830": "기본 기본 참조 없음" }, "BrowserDefaultZoomSetting": { @@ -4495,7 +4495,7 @@ "64898ecdab": "생성", "7b649a578a": "만드는 중…", "4399c77caa": "기본", - "4af9a17947": "카기", + "4af9a17947": "kagi", "c0f85056d9": "Browser profiles on this Orca server.", "86b7c83fee": "This computer", "6480776a03": "Browser profiles for the selected host.", @@ -4527,7 +4527,7 @@ "1188e56af4": "예시 프롬프트 복사", "b84807f228": "\"", "59722f31b4": "\"", - "c5325e91f6": "이 중 하나를 Claude Code, Codex 또는 해당 스킬이 설치된 프로젝트의 다른 agent 에 붙여넣으세요.", + "c5325e91f6": "이 중 하나를 Claude Code, Codex 또는 해당 스킬이 설치된 프로젝트의 다른 agent에 붙여넣으세요.", "2a180694f7": "시도해 보세요 - 예시 프롬프트", "5ec620ccc4": "복사하지 못했습니다.", "a602d43069": "{{value0}}을(를) 복사했습니다." @@ -4552,7 +4552,7 @@ "2ea4617e3a": "{{value1}}{{value2}}에서 {{value0}} 쿠키를 가져왔습니다.", "721aee31b4": "PATH에 Orca CLI를 등록했습니다.", "180a9abf3a": "CLI 상태를 로드하지 못했습니다.", - "2ccfc9cff8": "수입", + "2ccfc9cff8": "가져오기", "0462565413": "다시 가져오기", "de9b2f32f3": "활성화", "ad8cb0ee22": "경로 수정", @@ -4615,14 +4615,14 @@ "CommitMessageAiPane": { "841ed9884a": "소스 제어 AI를 사용자 정의하지 않은 리포지토리에서 사용됩니다.", "ad66ff886d": "소스 제어 AI 기본값", - "347094560b": "전역 호스팅 PR 기본값을 상속하는 저장소에서 사용됩니다.", + "347094560b": "전역 호스팅 PR 기본값을 상속하는 repos에서 사용됩니다.", "2dafc7646e": "호스팅 리뷰 생성 기본값", "e9d46a544d": "호스팅 PR 작성기가 열릴 때 사용되는 기본값입니다.", "b125eabffa": "제출 후 브라우저에서 생성된 호스팅 리뷰를 엽니다.", "7662715213": "생성 후 호스팅 PR 열기", "b27b0809f3": "작성기가 열리면 호스팅 PR 세부 정보 생성을 한 번 실행합니다.", "d5f0de6309": "PR 생성을 열 때 세부정보 생성", - "6278c0ce43": "설명이 설정되지 않은 경우 저장소 PR 템플릿을 선호합니다.", + "6278c0ce43": "설명이 설정되지 않은 경우 repos PR 템플릿을 선호합니다.", "d8b6764d79": "가능한 경우 리뷰 템플릿을 사용하세요.", "e001734396": "작성기에서 변경하지 않는 한 호스팅된 리뷰를 초안으로 생성합니다.", "6ba48f07a4": "기본적으로 초안", @@ -4639,7 +4639,7 @@ "34d0348e34": "생성", "8cd2be0948": "메시지", "ca433708cb": "commit", - "0b7eafe55f": "일체 포함", + "0b7eafe55f": "ai", "2c5436c018": "열림", "6c84ba6de3": "주형", "ebed4d2a29": "초안", @@ -4679,7 +4679,7 @@ "DeveloperPermissionsPane": { "4c17304beb": "새로고침", "6326a4c5cc": "CLI, 로컬 앱 또는 자동화 도구에 macOS 개인 정보 보호 액세스가 필요한 경우 이러한 컨트롤을 사용하십시오. Orca는 시작 시 묻지 않습니다.", - "6f011b9bf6": "Terminal도구는 Orca의 macOS 개인 정보 보호 봉투를 상속합니다.", + "6f011b9bf6": "Terminal 도구는 Orca의 macOS 개인 정보 보호 봉투를 상속합니다.", "bfa3402305": "권한을 요청할 수 없습니다.", "66e94d6cf3": "권한 요청이 전송되었습니다.", "fa809e8ada": "macOS 개인정보 보호 및 보안 오픈", @@ -4719,8 +4719,8 @@ "dd6f0a1d45": "애완 동물", "0e89a574ae": "오른쪽 하단에 떠 있는 애니메이션 애완동물.", "agentHibernation": { - "copy": "설정된 유휴 시간이 지난 백그라운드 agent 터미널을 중지하고, 다시 열 때 지원되는 세션을 재개합니다. 안전 모델을 조정하는 동안 제공되는 실험적 기능입니다.", - "description": "설정된 유휴 시간이 지난 백그라운드 agent 터미널을 중지하고, 다시 열 때 지원되는 세션을 재개합니다.", + "copy": "설정된 유휴 시간이 지난 백그라운드 agent terminals을 중지하고, 다시 열 때 지원되는 세션을 재개합니다. 안전 모델을 조정하는 동안 제공되는 실험적 기능입니다.", + "description": "설정된 유휴 시간이 지난 백그라운드 agent terminals을 중지하고, 다시 열 때 지원되는 세션을 재개합니다.", "idleMinutesDescription": "완료된 백그라운드 agent를 Orca가 최대 절전 모드로 전환하기 전에 기다릴 유휴 시간(분)입니다.", "idleMinutesLabel": "최대 절전까지", "idleMinutesSuffix": "분", @@ -4855,7 +4855,7 @@ "28bc3d085e": "컨텍스트 메뉴에서 워크스페이스를 삭제하기 전에 확인 메시지를 표시합니다. 삭제에 실패해도 여전히 강제 삭제 대체가 나타납니다.", "9f380934cf": "워크스페이스를 삭제하기 전에 확인", "5734db82af": "워크스페이스를 삭제하기 전에 확인 대화상자를 표시합니다.", - "4fbf910ded": "repo이름이 지정된 하위 폴더 내에 워크스페이스를 만듭니다.", + "4fbf910ded": "repo 이름이 지정된 하위 폴더 내에 워크스페이스를 만듭니다.", "ba3480642f": "네스트 워크스페이스", "a246f5ce6f": "워크스페이스 폴더가 생성되는 루트 디렉터리입니다.", "5567191a6e": "먹다", @@ -4897,13 +4897,13 @@ "8a527d48e3": "gitlab", "aa204f185f": "현재 GitHub CLI REST, 검색 및 GraphQL 속도 제한.", "612a440e57": "GitHub API 예산", - "2cde9044a8": "그래프", + "2cde9044a8": "graphql", "36e3de3619": "오래된 역사와 비교하는 것에서. Orca는 해당 브랜치에 commits 되지 않은 변경 사항이나 로컬 전용 commits이 있는 경우 업데이트를 건너뜁니다.", "d072a12995": "git diff 메인...HEAD", "db3a127eb1": ". 이것은 다음과 같은 명령을 유지합니다", - "3ae3de8898": "주인", + "3ae3de8898": "master", "5bf885be48": "또는", - "ffba483bae": "기본", + "ffba483bae": "main", "976afc6b3e": "워크스페이스를 생성하면 Orca는 원격 기반을 새로고침하고 다음과 같은 일치하는 로컬 브랜치를 안전하게 빨리 감습니다.", "1ec5c91e1d": "브랜치 이름에 Git 사용자 이름, 사용자 지정 접두사 또는 접두사 없음을 사용할지 선택합니다.", "330f584b50": "브랜치 접두사", @@ -4916,7 +4916,7 @@ "f35007e6e8": "자식 사용자 이름", "3d172725cc": "없음", "1f32ba27a6": "Custom", - "a182c5125e": "힘내 사용자 이름" + "a182c5125e": "Git 사용자 이름" }, "HiddenExperimentalGroup": { "d0f914a528": "자리 표시자 토글", @@ -4938,14 +4938,14 @@ "264a9b6128": "Linear", "4831ba1083": "재확인", "01f6c7582e": "자세히 알아보기", - "1a62c295c6": "Gitea 자격 증명이 구성되었지만 인증할 수 없습니다. 토큰, API 기본 URL 및 저장소 권한을 확인한 후 환경 변수가 변경된 경우 Orca를 다시 시작하십시오.", + "1a62c295c6": "Gitea 자격 증명이 구성되었지만 인증할 수 없습니다. 토큰, API 기본 URL 및 repos 권한을 확인한 후 환경 변수가 변경된 경우 Orca를 다시 시작하십시오.", "5a1f86225a": "Orca가 원격지에서 API URL을 파생할 수 없는 경우에만 가능합니다.", "6193444689": "ORCA_GITEA_API_BASE_URL", - "2c0330ec3e": "개인 저장소의 경우", + "2c0330ec3e": "개인 repos의 경우", "e678d89e8c": "ORCA_GITEA_TOKEN", - "d9467ab026": "공개 저장소는 git 원격에서 감지됩니다. 세트", + "d9467ab026": "공개 repos는 git 원격에서 감지됩니다. 세트", "4ab9b96925": "Gitea", - "953b7bf6f7": "Azure DevOps 자격 증명이 구성되었지만 인증할 수 없습니다. 토큰, API 기본 URL 및 저장소 권한을 확인한 후 환경 변수가 변경된 경우 Orca를 다시 시작하십시오.", + "953b7bf6f7": "Azure DevOps 자격 증명이 구성되었지만 인증할 수 없습니다. 토큰, API 기본 URL 및 repos 권한을 확인한 후 환경 변수가 변경된 경우 Orca를 다시 시작하십시오.", "6f317f5132": "Orca가 git 원격에서 API 기본 URL을 파생할 수 없는 경우에만 가능합니다.", "ae6b7f5f40": "ORCA_AZURE_DEVOPS_API_BASE_URL", "67a9f26a80": ". 세트", @@ -4954,12 +4954,12 @@ "5ee6ef6405": "ORCA_AZURE_DEVOPS_TOKEN", "4ee74d1470": "세트", "5efce6953d": "애저 데브옵스", - "3c3cf05c63": "Bitbucket 자격 증명이 구성되었지만 인증할 수 없습니다. 토큰 및 저장소 권한을 확인한 후 환경 변수가 변경된 경우 Orca를 다시 시작하십시오.", + "3c3cf05c63": "Bitbucket 자격 증명이 구성되었지만 인증할 수 없습니다. 토큰 및 repos 권한을 확인한 후 환경 변수가 변경된 경우 Orca를 다시 시작하십시오.", "6e0ff3403e": "ORCA_BITBUCKET_ACCESS_TOKEN", "44cde4aa01": "ORCA_BITBUCKET_API_TOKEN", "a6c2816115": "그리고", "b8a7efb3f6": "ORCA_BITBUCKET_EMAIL", - "8489c0aa49": "비트버킷", + "8489c0aa49": "Bitbucket", "e74de656ce": "glab 인증 로그인", "05e5245af7": "GitLab CLI가 설치되었지만 인증되지 않았습니다. terminal에서 다음 명령을 실행하세요.", "a83cac5726": "GitLab CLI 설치", @@ -4992,7 +4992,7 @@ "1fac9b4910": "{{value0}} · PR 및 commit 상태", "f92fbf11aa": "구성되지 않음", "6791d7af95": "Azure DevOps REST API 토큰을 통해 PR 및 빌드 상태를 확인하세요.", - "e3d5a24979": "감지된 Azure Repos 에 대한 PR 및 빌드 상태", + "e3d5a24979": "감지된 Azure Repos에 대한 PR 및 빌드 상태", "277fc23929": "{{value0}} · PR 및 빌드 상태", "295154e54e": "연결됨", "0879860c58": "Bitbucket Cloud API 토큰을 통해 PR 및 빌드 상태를 확인하세요.", @@ -5092,7 +5092,7 @@ "c12b253997": "예시 프롬프트 복사", "d151e25078": "\"", "b525ff2b12": "\"", - "4daa95f25a": "Orca CLI 스킬이 설치된 프로젝트의 Claude Code, Codex 또는 다른 agent 에 이들 중 하나를 붙여넣습니다.", + "4daa95f25a": "Orca CLI 스킬이 설치된 프로젝트의 Claude Code, Codex 또는 다른 agent에 이들 중 하나를 붙여넣습니다.", "0820b3f84f": "시도해 보세요 - 예시 프롬프트", "1f608e7d60": "프롬프트를 복사하지 못했습니다.", "2b077b5544": "프롬프트를 복사했습니다." @@ -5105,7 +5105,7 @@ "ae1612c58c": "유효성", "f9af91ea26": "새 모바일 에뮬레이터 작업을 표시하고 agents가 활성 에뮬레이터에 연결할 수 있도록 합니다.", "700ddbf9b1": "모바일 에뮬레이터 활성화", - "bc39d0f115": "Orca 및 코딩 agents 에 대한 모바일 에뮬레이터 지원을 구성합니다.", + "bc39d0f115": "Orca 및 코딩 agents에 대한 모바일 에뮬레이터 지원을 구성합니다.", "6593c9ddd3": "모바일 에뮬레이터", "a4f1c82d90": "비활성", "b5e2d93e01": "확인 중...", @@ -5117,7 +5117,7 @@ "87985ba6f5": "이 네트워크 인터페이스 메뉴에서 Tailscale 주소(일반적으로 100.x.y.z IP)를 선택합니다.", "1f7c26d36a": "두 장치 모두에서 동일한 tailnet에 로그인합니다.", "668016be7a": "컴퓨터와 휴대폰에서.", - "1dc87a7fbc": "테일스케일", + "1dc87a7fbc": "Tailscale", "51d29927eb": "설치", "9fc5d203ff": "Orca Mobile은 이 컴퓨터에 직접 연결됩니다. 동일한 로컬 네트워크가 아닌 곳에서 사용하려면 컴퓨터와 휴대폰을 동일한 개인 오버레이 네트워크에 놓은 다음 해당 네트워크 주소를 선택하여 QR 코드를 생성하세요.", "39fad211d9": "tailnet을 사용하여 Wi-Fi 외부에서 연결", @@ -5130,7 +5130,7 @@ }, "MobilePane": { "dd3cd78d04": "Orca 모바일로 스캔", - "35100bca5d": "휴대폰에서 terminal을 사용하는 동안 Orca는 휴대폰 화면에 맞게 terminal을 축소합니다. 앱을 닫거나 다른 곳으로 전환하면 앱이 휴대폰 크기로 유지되는지(대화형 CLI 도구가 리플로우되지 않음) 또는 데스크탑으로 다시 크기가 조정되는지 여부가 제어됩니다. 배너에서 이 터미널 복원 또는 모든 터미널 복원을 사용해 언제든지 수동으로 크기를 조정할 수 있습니다.", + "35100bca5d": "휴대폰에서 terminal을 사용하는 동안 Orca는 휴대폰 화면에 맞게 terminal을 축소합니다. 앱을 닫거나 다른 곳으로 전환하면 앱이 휴대폰 크기로 유지되는지(대화형 CLI 도구가 리플로우되지 않음) 또는 데스크탑으로 다시 크기가 조정되는지 여부가 제어됩니다. 배너에서 이 terminals 복원 또는 모든 terminals 복원을 사용해 언제든지 수동으로 크기를 조정할 수 있습니다.", "ee56f1c7e4": "모바일 앱을 나갈 때", "3939fd062c": "장치를 취소하면 즉시 연결이 끊어집니다.", "254a6d09e4": "페어링됨", @@ -5215,7 +5215,7 @@ "e4064916aa": "앱 추가", "9d0413817d": "워크스페이스의 열기 메뉴에서 사용 가능한 앱을 선택하세요.", "6ed52fe71e": "앱에서 열기", - "eb55b87570": "이 앱을 열려면 Terminal 에 입력하는 명령입니다.", + "eb55b87570": "이 앱을 열려면 Terminal에 입력하는 명령입니다.", "ba1422ee07": "Terminal 명령", "e1fc0085c6": "메뉴 라벨", "a261931d29": "앱 삭제", @@ -5234,7 +5234,7 @@ "80c6f2feb8": "예시 프롬프트를 복사했습니다." }, "OrchestrationPane": { - "52e0634e2c": "핸드오프, 작업 트리 핸드오버, 순차 또는 병렬 하위 agents 에 대한 오케스트레이션을 사용하도록 코디네이터 agents 에 요청하세요.", + "52e0634e2c": "핸드오프, 작업 트리 핸드오버, 순차 또는 병렬 하위 agents에 대한 오케스트레이션을 사용하도록 코디네이터 agents에 요청하세요.", "ae79504732": "사용방법", "7bc082f4de": "설치 명령 복사", "832f1f3ee6": "자신의 terminal을 선호하시나요?", @@ -5256,7 +5256,7 @@ "f08d45293d": "명령 복사", "35550f3b3b": "완료", "1bdce1911e": "오케스트레이션 스킬 설치 명령 복사", - "b99f375eb2": "agents 에 대한 오케스트레이션 스킬을 설치하려면 terminal에서 이 명령을 실행하세요.", + "b99f375eb2": "agents에 대한 오케스트레이션 스킬을 설치하려면 terminal에서 이 명령을 실행하세요.", "2914abcfa2": "오케스트레이션 스킬 설치", "d3dc559225": "설치 명령을 복사하지 못했습니다.", "239bf9132b": "설치 명령을 복사했습니다." @@ -5359,7 +5359,7 @@ "b2b06c7ce8": "사용 가능한 환경 변수(자세한 내용을 보려면 마우스를 올리세요):", "95a0411b3e": "주형", "175daba180": "예", - "b20c5df6ca": "이 repo 에 대한 공유 설정, 아카이브 또는 발행 자동화 기본값을 활성화하려면 `orca.yaml` 파일을 추가하세요. 예제 템플릿:", + "b20c5df6ca": "이 repo에 대한 공유 설정, 아카이브 또는 발행 자동화 기본값을 활성화하려면 `orca.yaml` 파일을 추가하세요. 예제 템플릿:", "56f9a4a1d0": "`orca.yaml` 사용 중", "623e0c9f31": "`orca.yaml`을 구문 분석할 수 없습니다", "5a67e4793d": "`orca.yaml`을 찾을 수 없습니다", @@ -5370,7 +5370,7 @@ "0cc712b823": "핵심 구성 파일이 repo 루트에 있지만 Orca가 아직 지원되는 후크 정의를 구문 분석할 수 없습니다.", "c90b858573": "텍스트-호박-700 어두움:텍스트-호박-300", "aba825233f": "파일에는 이 버전의 Orca가 인식하지 못하는 구성 키가 포함되어 있습니다. Orca를 업데이트하거나 파일에 오타가 있는지 확인해야 할 수도 있습니다.", - "ca424ff135": "공유 후크 및 이슈 자동화 기본값은 repo 에 정의되어 있으며 이를 사용하는 모든 사람이 사용할 수 있습니다.", + "ca424ff135": "공유 후크 및 이슈 자동화 기본값은 repo에 정의되어 있으며 이를 사용하는 모든 사람이 사용할 수 있습니다.", "32f417fe17": "텍스트-에메랄드-700 다크:텍스트-에메랄드-300", "8bfe65fc60": "로컬 명령 사용", "8d6c56bff8": "둘 다 실행", @@ -5426,7 +5426,7 @@ "549d126081": "재설정", "4e2a14f967": "Repo 아이콘", "d71df44587": "GitHub repo를 확인하지 못했습니다.", - "f79972271a": "이 repo 에 대한 GitHub 원격을 찾을 수 없습니다.", + "f79972271a": "이 repo에 대한 GitHub 원격을 찾을 수 없습니다.", "4d039317f4": "웹사이트 파비콘", "acf31559a0": "유효한 웹사이트 URL을 입력하세요.", "868c5c9b56": "repo 아이콘을 가져오지 못했습니다." @@ -5451,15 +5451,15 @@ "availableHostsDescription": "Hosts where this project is set up.", "availableHostsHelp": "Project paths and worktree settings are host-specific; creating a workspace can target any ready setup.", "viewingHost": "Viewing host", - "currentSetup": "Current", - "hostSetupStateReady": "Ready", + "currentSetup": "현재", + "hostSetupStateReady": "준비됨", "hostSetupStateNotSetUp": "Not set up", "hostSetupStateSettingUp": "Setting up", "hostSetupStateError": "Error", "hostSetupStateUnsupported": "Unsupported", "setupPathPending": "Path pending", "openSetup": "Open", - "removeSetup": "Remove", + "removeSetup": "제거", "hostSetupBlockedVersion": "Orca server version is incompatible", "hostSetupMissingCapability": "Update Orca on this host to set up projects", "setupProjectOnHost": "Set up on another host", @@ -5472,7 +5472,7 @@ "setupKindGit": "Git repo", "setupKindFolder": "Folder", "settingUpHost": "Importing...", - "setupHost": "Import", + "setupHost": "가져오기", "cloningHost": "Cloning...", "cloneHost": "Clone", "creatingPendingSetup": "Creating...", @@ -5483,7 +5483,7 @@ "addToAnotherHost": "Add to another host", "addProjectHost": "Add project to host", "addProjectHostHelp": "Choose where this project should also be available.", - "closeHostSetup": "Close", + "closeHostSetup": "닫기", "setupHostLabel": "Host", "browseFolder": "Browse folder", "browseFolderHelp": "Use an existing checkout or folder on this host.", @@ -5509,7 +5509,7 @@ "f0aa2cfaea": "액션 레시피" }, "RepositorySourceControlAiCustomCommand": { - "0704dd55cd": "저장소 명령", + "0704dd55cd": "Repos 명령", "e56668c291": "글로벌 사용", "fbb77e122a": "사용자 정의 명령을 선택하는 텍스트 작업에 대한 Repo 대체입니다.", "ebffc5a28c": "사용자 정의 명령", @@ -5535,7 +5535,7 @@ }, "RepositorySourceControlAiSection": { "67b3ff5467": "버리기", - "8b8bc5913a": "저장소 작업 레시피. 이 저장소가 사용자 정의할 때까지 전역 설정이 사용됩니다.", + "8b8bc5913a": "Repos 작업 레시피. 이 Repos가 사용자 정의할 때까지 전역 설정이 사용됩니다.", "71b003b62b": "소스 제어 AI", "152268c295": "저장", "57e6e9d4b1": "저장 중...", @@ -5560,7 +5560,7 @@ "bb90dd6487": "서버 제거", "d2e00809e4": "전환", "05e0fc3ebf": "전환 대상", - "b2290ed203": "Orca가 이 호스트로 포커스를 옮기고 해당 프로젝트를 로드합니다. 다른 호스트의 기존 터미널과 브라우저 탭은 계속 유지됩니다.", + "b2290ed203": "Orca가 이 호스트로 포커스를 옮기고 해당 프로젝트를 로드합니다. 다른 호스트의 기존 terminals과 브라우저 탭은 계속 유지됩니다.", "d570c35a99": "서버 전환", "84b9b2be05": "브라우저나 다른 Orca 클라이언트가 연결할 수 있도록 취소 가능한 액세스 권한을 생성합니다.", "6e1280ca55": "이 Orca 서버 공유", @@ -5609,7 +5609,7 @@ "hostModelCapabilityProjectSetup": "프로젝트 설정", "hostModelCapabilityTaskSourceContext": "작업 소스 컨텍스트", "hostModelCapabilityWorkspaceRunContext": "워크스페이스 실행 컨텍스트", - "3f67e8078a": "기본적으로 이 컴퓨터를 사용합니다. 지원되는 프로젝트, 파일, 터미널, 공급자 확인, 브라우저/모바일 핸드오프를 해당 서버를 통해 실행하려는 경우에만 저장된 서버를 선택하세요.", + "3f67e8078a": "기본적으로 이 컴퓨터를 사용합니다. 지원되는 프로젝트, 파일, terminals, 공급자 확인, 브라우저/모바일 핸드오프를 해당 서버를 통해 실행하려는 경우에만 저장된 서버를 선택하세요.", "2c85efb3e8": "저장된 서버를 선택하면 이 브라우저가 페어링된 Orca 런타임을 기본 호스트로 사용합니다.", "serverConnected": "연결됨", "serverChecking": "확인 중…", @@ -5617,7 +5617,7 @@ "disconnectedServer": "{{value0}}에서 연결이 해제되었습니다.", "connectToRemoteServers": "원격 서버에 연결", "connectToRemoteServersHelp": "다른 Orca 런타임을 페어링한 다음 여기에서 연결하거나 연결 해제하세요. 기본 호스트를 변경하려는 경우에만 고급 > 활성 서버를 사용하세요.", - "activeServerRowHelp": "서버를 통해 라우팅되는 프로젝트, 터미널, 공급자 확인에 사용할 활성 서버입니다.", + "activeServerRowHelp": "서버를 통해 라우팅되는 프로젝트, terminals, 공급자 확인에 사용할 활성 서버입니다.", "disconnect": "연결 해제", "connect": "연결", "advanced": "고급", @@ -5679,7 +5679,7 @@ "954a8f5aef": "통계 및 사용량", "a737a4bb22": "일반 작업에 대한 키보드 단축키입니다.", "23bf7a1ad4": "단축키", - "7210ac09c4": "agent 활동 및 terminal이벤트에 대한 기본 데스크톱 알림입니다.", + "7210ac09c4": "agent 활동 및 terminal 이벤트에 대한 기본 데스크톱 알림입니다.", "9907545fa3": "알림", "d0b7021d64": "선택 및 편집 동작.", "d7a3e635b6": "입력 및 편집", @@ -5687,7 +5687,7 @@ "2b4474780a": "외관", "3d9adfe6a5": "전역 terminal, 브라우저 및 markdown 탭.", "3eb22a3ada": "플로팅 워크스페이스", - "01f9d36292": "Orca 및 코딩 agents 에 대한 모바일 에뮬레이터 지원을 구성합니다.", + "01f9d36292": "Orca 및 코딩 agents에 대한 모바일 에뮬레이터 지원을 구성합니다.", "f75daf1002": "모바일 에뮬레이터", "ad9788036f": "홈페이지, 링크 라우팅 및 세션 쿠키.", "c46215ea03": "브라우저", @@ -5723,7 +5723,7 @@ "17bdee4ff1": "저장되지 않은 Git AI Author 변경사항을 삭제하시겠습니까?", "084d8fac5b": "개인 정보 보호 및 보안", "23931df7e8": "Remote Hosts", - "mobile_group": "Mobile", + "mobile_group": "모바일", "8bd117d669": "인터페이스", "e1578cd4bc": "워크플로", "9abb9be3bc": "설정 시작", @@ -5798,9 +5798,9 @@ "47f8f7aef9": "키보드 단축키", "f0b35b0b2e": "terminal 또는 TUI에 키보드 포커스가 있는 동안에는 비활성화됩니다.", "5c65d5db9d": "Terminal 우선", - "dfa8ff612f": "terminal이나 TUI에 키보드 포커스가 있는 동안에도 실행됩니다.", + "dfa8ff612f": "terminal 이나 TUI에 키보드 포커스가 있는 동안에도 실행됩니다.", "2a0e8aeccf": "Orca 먼저", - "3c0fac059a": "terminal 에 키보드 포커스가 있는 동안에도 계속 실행됩니다.", + "3c0fac059a": "terminal에 키보드 포커스가 있는 동안에도 계속 실행됩니다.", "25b0004fbf": "Terminal 활성", "781cb74d22": "terminal 패널에서 실행됩니다.", "cb02e00202": "Terminal", @@ -5836,7 +5836,7 @@ "SparsePresetSettingsSection": { "6fa754d20f": "삭제", "fe1f2c6572": "{{value0}} 편집", - "88bfbf1a9c": "이 저장소에 저장된 희소 사전 설정이 없습니다.", + "88bfbf1a9c": "이 repos에 저장된 희소 사전 설정이 없습니다.", "d7565029a9": "새로운 사전 설정", "17f8c4ce10": "스파스 작업 트리 생성을 위해 저장된 디렉터리 세트를 관리합니다.", "388513be2d": "스파스 체크아웃 사전 설정", @@ -5848,7 +5848,7 @@ "3b6f1abd3e": "예를 들어 웹 전용", "a6fcdd9e3c": "이름", "b9922ec194": "사전 설정 편집 취소", - "694cc55ecb": "저장된 디렉터리는 이 저장소에 대한 희소 작업 트리를 생성할 때 사용됩니다.", + "694cc55ecb": "저장된 디렉터리는 이 repos에 대한 희소 작업 트리를 생성할 때 사용됩니다.", "8b64731aaf": "+{{value0}}개 더", "755c6a1a0d": "확인", "a7bcf206b1": "삭제 중", @@ -5870,7 +5870,7 @@ "SshPane": { "c0f1c80166": "구성된 SSH 대상이 없습니다.", "639ceb3698": "대상 추가", - "51d7dba44d": "수입", + "51d7dba44d": "가져오기", "a7d28dff81": "Orca에서 연결할 원격 호스트를 추가합니다.", "94c5284560": "대상", "f495689b82": "가져오기 실패", @@ -6062,7 +6062,7 @@ "6c6a054a1c": "새 탭에서 실행", "a9d47451d1": "\"새 탭\"은 포커스를 잃지 않고 \"설정\"이라는 제목의 배경 탭에서 설정 명령을 엽니다.", "d23b43c5be": "설정 스크립트 위치", - "34a0dfa06e": "새 워크스페이스가 생성될 때 저장소 설정 스크립트가 실행되는 위치입니다.", + "34a0dfa06e": "새 워크스페이스가 생성될 때 repos 설정 스크립트가 실행되는 위치입니다.", "21f8da2078": "워크스페이스 설정 스크립트", "6e6480a7df": "terminal의 프로그램(tmux, Neovim, fzf, SSH)을 시스템 클립보드에 복사할 수 있습니다.", "3338dcf8c1": "TUI 클립보드 쓰기 허용(OSC 52)", @@ -6087,21 +6087,21 @@ "2503f1e86b": "활성 워크스페이스가 아직 WSL 내부에 있지 않은 경우 새 WSL terminal 패널 및 로컬 agent 감지에 사용됩니다.", "5fe79a5e56": "새 WSL terminals 및 로컬 agent 검색에서 사용할 WSL 배포를 선택합니다.", "b637dd57a7": "WSL", - "f61ac77f16": "힘내 배쉬", + "f61ac77f16": "Git Bash", "0f1b8669e6": "명령 프롬프트", - "eb7fc4d98a": "파워셸", + "eb7fc4d98a": "PowerShell", "27e301f22c": "기본 쉘", - "09bf02de9a": "새 terminals 패널을 열 때 사용되는 셸입니다. 새 terminals 에 적용됩니다.", + "09bf02de9a": "새 terminals 패널을 열 때 사용되는 셸입니다. 새 terminals에 적용됩니다.", "bd68f3170d": "Windows의 새 terminal 패널에 대한 기본 셸을 선택합니다.", "a55eee649f": "Windows의 새 terminal 패널에 대한 기본 셸입니다.", "87e678a8af": "윈도우 셸", - "05efc0bada": "진실", - "348246b06f": "거짓", + "05efc0bada": "true", + "348246b06f": "false", "5936387ddd": "자동", "adbafefe56": "custom", "16753eea48": "Windows에서는 마우스 오른쪽 버튼을 클릭하여 클립보드를 붙여넣습니다. Ctrl+마우스 오른쪽 버튼을 클릭하면 컨텍스트 메뉴가 열립니다.", "9c178cf8aa": "붙여넣으려면 마우스 오른쪽 버튼을 클릭하세요.", - "af0c3b6e39": "Windows에서는 마우스 오른쪽 버튼을 클릭하여 클립보드를 terminal 에 붙여넣습니다. 컨텍스트 메뉴를 열려면 Ctrl+오른쪽 클릭을 사용하세요.", + "af0c3b6e39": "Windows에서는 마우스 오른쪽 버튼을 클릭하여 클립보드를 terminal에 붙여넣습니다. 컨텍스트 메뉴를 열려면 Ctrl+오른쪽 클릭을 사용하세요.", "29154326bb": "~에", "ab20575a8a": "끄다", "ab3a1f9068": "wsl.exe", @@ -6138,7 +6138,7 @@ "03c855d15f": "모든 색상 재정의 재설정", "63f8d9336e": "색상 재정의", "e86e09b5c7": "개별 terminal 색상을 재정의합니다.", - "1d1920dc8a": "terminal 에 입력할 때 마우스 커서를 숨깁니다.", + "1d1920dc8a": "terminal에 입력할 때 마우스 커서를 숨깁니다.", "3530908ef9": "입력하는 동안 마우스 숨기기", "1846f6ee6a": "terminal 그리드 주변의 수직 패딩(픽셀)입니다.", "1afcc1d973": "수직 패딩", @@ -6241,7 +6241,7 @@ "WorktreeSymlinksSection": { "1c1e35b219": "{{value0}} 제거", "b814c618e2": "연결된 경로", - "31ebab5403": "이 저장소에 구성된 공유 경로가 없습니다.", + "31ebab5403": "이 repos에 구성된 공유 경로가 없습니다.", "ea06227efa": "추가됨", "b2429aeb31": "추가", "ab40b8a5f1": "일치하는 항목이 없습니다. 사용자 정의 경로를 추가하려면 계속 입력하세요.", @@ -6447,9 +6447,9 @@ "bce3ac317a": "자식", "7164edf71a": "파일 탐색기에서 .gitignore와 일치하는 희미한 파일.", "f8129fb544": "Git이 무시한 파일 표시", - "2f12e1aa3a": "UI", + "2f12e1aa3a": "ui", "5095258df2": "인터페이스", - "fab91464dd": "이드", + "fab91464dd": "ide", "8b36fb3f64": "타이포그래피", "a0e09aed9c": "서체", "24094af355": "폰트", @@ -6518,17 +6518,17 @@ }, "leftSidebarAppearance": { "title": "왼쪽 사이드바 모양", - "description": "왼쪽 사이드바를 터미널에 맞추거나 기본값을 유지하거나 은은한 색조를 적용합니다." + "description": "왼쪽 사이드바를 terminal에 맞추거나 기본값을 유지하거나 은은한 색조를 적용합니다." }, "workspaceCardLayout": { - "title": "작업 공간 카드 레이아웃", - "description": "작업 공간 사이드바 옵션 메뉴에서 작업 공간 카드를 컴팩트 또는 상세 보기로 전환합니다.", + "title": "워크스페이스 카드 레이아웃", + "description": "워크스페이스 사이드바 옵션 메뉴에서 워크스페이스 카드를 컴팩트 또는 상세 보기로 전환합니다.", "compact": "컴팩트", "compactDisplay": "컴팩트 보기", - "workspaceCards": "작업 공간 카드", + "workspaceCards": "워크스페이스 카드", "worktreeCards": "워크트리 카드", "cardLayout": "카드 레이아웃", - "workspaceOptions": "작업 공간 옵션", + "workspaceOptions": "워크스페이스 옵션", "detailed": "상세" } } @@ -6567,7 +6567,7 @@ "75a0d435b7": "크롬", "854ef6ce83": "로그인", "3910a41f32": "인증", - "2e7f951773": "수입", + "2e7f951773": "가져오기", "66dd641a47": "세션", "29193a51d5": "쿠키", "2d2d995c58": "브라우저", @@ -6592,9 +6592,9 @@ "072b7f5c1f": "기본 줌", "0bb34eacc9": "질문", "8b8ed06e4b": "omnibox", - "3538b3aaeb": "토큰", + "3538b3aaeb": "token", "0732ebe6fb": "사적인", - "e1c2a57f07": "카기", + "e1c2a57f07": "kagi", "ad40e75d13": "빙", "1f8153acfb": "덕덕고", "8a489aab8d": "Google", @@ -6624,7 +6624,7 @@ "088e7a9012": "크롬", "96ce3d2de2": "인증", "48557f639c": "로그인", - "d5ad1f7aad": "수입", + "d5ad1f7aad": "가져오기", "02837ee497": "세션", "fb8178824f": "쿠키", "ba4eb53b72": "브라우저 사용", @@ -6683,7 +6683,7 @@ "542e1a00a7": "codex", "f121bec167": "claude", "93e5210da8": "메시지", - "c33cb1b982": "일체 포함", + "c33cb1b982": "ai", "0b946b2abe": "소스 제어 commit, PR, 브랜치 이름 및 수정 작업에 대한 작업 레시피를 추가합니다.", "24dbdfca78": "소스 제어 AI 작업 표시" } @@ -6715,7 +6715,7 @@ "c4a4a02ea4": "USB", "fa3239cd42": "로컬 네트워크", "acad3d4743": "terminal 세션에서 사용되는 장치 및 로컬 네트워크 도구를 허용합니다.", - "3e0131e45d": "아이클라우드", + "3e0131e45d": "icloud", "ce07159ff5": "데스크탑", "a0c19119fb": "다운로드", "4438f81bfa": "서류", @@ -6794,11 +6794,11 @@ "agentHibernation": { "agent": "agent", "agents": "agents", - "description": "설정된 유휴 시간이 지난 백그라운드 agent 터미널을 중지하고, 다시 열 때 지원되는 세션을 재개합니다.", + "description": "설정된 유휴 시간이 지난 백그라운드 agent terminals을 중지하고, 다시 열 때 지원되는 세션을 재개합니다.", "hibernate": "최대 절전", "minutes": "분", "sleep": "절전", - "terminal": "터미널", + "terminal": "terminal", "title": "Agent 최대 절전" }, "newWorktreeCardStyle": { @@ -6965,7 +6965,7 @@ "93f6ec5e70": "예배 규칙서", "9bde064915": "하위 폴더", "ec5049e510": "중첩된", - "b9cffd374d": "repo이름이 지정된 하위 폴더 내에 워크스페이스를 만듭니다.", + "b9cffd374d": "repo 이름이 지정된 하위 폴더 내에 워크스페이스를 만듭니다.", "141f71c69f": "네스트 워크스페이스", "7887a2c262": "폴더", "7baf524b04": "워크스페이스", @@ -6995,7 +6995,7 @@ "4808f065b3": "gitlab", "2b4a72885d": "사용 가능한 경우 현재 GitLab CLI REST 속도 제한 헤더입니다.", "83ecb3f470": "GitLab API 예산", - "65b69d9f80": "그래프", + "65b69d9f80": "graphql", "1139f61512": "현재 GitHub CLI REST, 검색 및 GraphQL 속도 제한.", "ff86e354c4": "GitHub API 예산", "035134fcd9": "작업 트리", @@ -7008,8 +7008,8 @@ "c41e345153": "메인 뒤에", "6ee3cfff02": "자식 비교", "564942ffc5": "원산지/주요", - "28192e3a63": "주인", - "e3e9adde59": "기본", + "28192e3a63": "master", + "e3e9adde59": "main", "0e993bf00f": "워크스페이스를 생성하면 Orca는 원격 기반을 새로고침하고 기본 또는 마스터와 같은 일치하는 로컬 브랜치를 안전하게 빨리 감습니다. 이렇게 하면 git diff main...HEAD와 같은 명령이 오래된 기록과 비교되지 않습니다. Orca는 해당 브랜치에 commits 되지 않은 변경 사항이나 로컬 전용 commits이 있는 경우 업데이트를 건너뜁니다.", "f8bda25f29": "로컬 메인을 최신 상태로 유지", "769ddd7f81": "custom", @@ -7061,7 +7061,7 @@ "b38b5d27f1": "Azure DevOps", "7b1f3984bb": "토큰 환경 변수를 통한 Azure DevOps Repos 인증.", "af6611fa6e": "Azure DevOps 연동", - "50d20817f7": "Bitbucket", + "50d20817f7": "bitbucket", "c97d58a0f3": "API 토큰 환경 변수를 통한 Bitbucket Cloud 인증.", "67a2a0e868": "Bitbucket 연동", "371ee914d2": "MR", @@ -7143,7 +7143,7 @@ "2d67f708ce": "시뮬레이터", "c5eca29310": "iOS 시뮬레이터", "25159de808": "모바일 에뮬레이터", - "9595354cff": "Orca 및 코딩 agents 에 대한 모바일 에뮬레이터 지원을 구성합니다.", + "9595354cff": "Orca 및 코딩 agents에 대한 모바일 에뮬레이터 지원을 구성합니다.", "cdd3c31918": "모바일 에뮬레이터" } }, @@ -7160,7 +7160,7 @@ "6cd2bfdb0e": "복원", "b34ad5b3a7": "terminal", "6db86f445f": "모바일", - "707fc78052": "앱을 닫거나 다른 곳으로 전환한 후 모바일에서 보고 있던 terminals 에 어떤 일이 발생하는지 선택하세요.", + "707fc78052": "앱을 닫거나 다른 곳으로 전환한 후 모바일에서 보고 있던 terminals에 어떤 일이 발생하는지 선택하세요.", "1e711aca11": "모바일 앱을 나갈 때", "126afc5dbd": "원격", "70f505f3c3": "란", @@ -7170,7 +7170,7 @@ "d0c89bc4a9": "씌우다", "87711f4b8f": "VPN", "16bff559a0": "테일넷", - "c690e3ee38": "테일스케일", + "c690e3ee38": "tailscale", "a023683767": "인터페이스", "7b37c2e557": "회로망", "3190ef67a4": "모바일 페어링에 사용할 네트워크 주소를 선택하세요.", @@ -7194,7 +7194,7 @@ "b730ff7049": "실험적인", "8d4ba0ef09": "베타", "6bfa001752": "APK", - "a7eececc1d": "기계적 인조 인간", + "a7eececc1d": "android", "7e801801ac": "원격", "0b7e585cb9": "주사", "59b1d75fd1": "암호", @@ -7308,12 +7308,12 @@ "quick": { "commands": { "search": { - "3c316e6ef8": "방사", + "3c316e6ef8": "yarn", "b86c727100": "npm", "b949a7c0a0": "pnpm", "0b78c4a165": "실행", "2d8aff42be": "달리다", - "1c5bdcd0f2": "저장소", + "1c5bdcd0f2": "repos", "89d2a9ad9f": "repo", "f58b92a48f": "프로젝트", "8bf43c2dad": "글로벌", @@ -7353,7 +7353,7 @@ "19f58d6d89": "고급의", "d141897c90": "명령 소스 및 orca.yaml 세부정보", "cc11699c3d": "고급", - "bf460fded8": "YAML", + "bf460fded8": "yaml", "9cad92fe77": "orca.yaml 후크", "6b80f7d3c8": "로컬 설정 스크립트", "a1a4c51d58": "아카이브 명령", @@ -7390,16 +7390,16 @@ "8068d8d0f1": "PR", "5ff7fe1ade": "PR", "eec39b3de6": "commit 메시지", - "cfad7ce5f3": "일체 포함", + "cfad7ce5f3": "ai", "a47f51127e": "소스 제어", "6cc5c65e64": "프로젝트별 Git 생성이 재정의됩니다.", "eec3995dc6": "Git AI Author", - "cc876ca5f2": "저장소", + "cc876ca5f2": "repos", "6469de5368": "프로젝트", "3067595d82": "삭제", "c86478c3d8": "Orca에서 이 프로젝트를 제거하세요.", "c5266c2c9d": "프로젝트 제거", - "4b9a18a56d": "모노레포", + "4b9a18a56d": "모노 repo", "4e2529722c": "디렉토리", "1ff4f12c0c": "예배 규칙서", "9f5ae26ccd": "사전 설정", @@ -7424,11 +7424,11 @@ "c1075178cf": "배지", "6d8de2f090": "마녀", "8d045419b1": "색상", - "b2546efab5": "저장소 아이콘", + "b2546efab5": "repos 아이콘", "6438a94c63": "프로젝트 아이콘", "a1f3a2bd47": "사이드바와 탭에 사용되는 프로젝트 아이콘과 색상입니다.", "b24f00294a": "프로젝트 아이콘", - "cd73b976d7": "저장소 이름", + "cd73b976d7": "repos 이름", "92af66c7ce": "프로젝트 이름", "883aad2801": "사이드바 및 탭에 대한 프로젝트별 표시 세부 정보입니다.", "7e1e456a95": "표시 이름", @@ -7493,7 +7493,7 @@ "a3058f3605": "연결 테스트", "2cd40ba0d0": "호스트", "5220501141": "구성", - "3b12e064a4": "수입", + "3b12e064a4": "가져오기", "7f251a45a8": "~/.ssh/config에서 호스트를 가져옵니다.", "41a3127094": "SSH 구성에서 가져오기", "f9493b80c0": "섬기는 사람", @@ -7540,7 +7540,7 @@ "62d1208b90": "OSC 52", "459fea094a": "SSH를 포함하여 OSC 52를 통해 terminal의 프로그램을 시스템 클립보드에 복사할 수 있습니다.", "74db8721e4": "TUI 클립보드 쓰기 허용(OSC 52)", - "4043e294d2": "금언", + "4043e294d2": "gnome", "cf83ac3dbd": "리눅스", "737cef6de1": "x11", "e87c6d776d": "오토매틱", @@ -7562,7 +7562,7 @@ "7a48c7715b": "워크스페이스", "6b659fff2a": "스크립트", "4529806908": "설정", - "2610ee3b56": "새 워크스페이스가 생성될 때 저장소 설정 스크립트가 실행되는 위치: 수직 분할(기본값), 수평 분할 또는 '설정'이라는 제목의 배경 탭.", + "2610ee3b56": "새 워크스페이스가 생성될 때 repos 설정 스크립트가 실행되는 위치: 수직 분할(기본값), 수평 분할 또는 '설정'이라는 제목의 배경 탭.", "5be2d67678": "설정 스크립트 위치", "0ce176909a": "테마", "4ba8623632": "팔레트", @@ -7575,7 +7575,7 @@ "34fe1af39d": "타자", "ee611ae238": "숨기기", "ea364ce6e4": "생쥐", - "77201c0bb2": "terminal 에 입력할 때 마우스 커서를 숨깁니다.", + "77201c0bb2": "terminal에 입력할 때 마우스 커서를 숨깁니다.", "d1fe5f99ff": "입력하는 동안 마우스 숨기기", "f25d948664": "여유", "b2f52cb96c": "간격", @@ -7613,8 +7613,8 @@ "6f5d486a68": "세션 관리", "10f9fb6fea": "설정", "2ade3ea490": "구성", - "fd752b3cac": "수입", - "82b63d07fe": "유령같은", + "fd752b3cac": "가져오기", + "82b63d07fe": "ghostty", "73e9422f19": "지원되는 Ghostty terminal 설정을 한 번만 가져옵니다.", "a979df0083": "Ghostty에서 가져오기", "4cec42dbf7": "국제", @@ -7696,7 +7696,7 @@ "8f9f953de7": "terminal이 xterm.js WebGL 렌더링을 사용하는지 여부를 제어합니다. 렌더러가 지원되면 자동으로 WebGL을 시도하며, 소프트웨어나 알 수 없는 GPU 렌더러에 대한 보수적인 폴백을 사용합니다.", "13a2502dfc": "GPU 가속", "d5e6c7fab1": "글꼴 기능", - "a16224d16a": "칼트", + "a16224d16a": "calt", "6ded6297fe": "이오세프카", "e3aeea308e": "캐스케이드 코드", "35c2311a33": "제트 브레인 모노", @@ -7741,7 +7741,7 @@ "e55186fe2b": "오른쪽 클릭", "28ff08ed35": "창문들", "e7d2793b03": "terminal", - "8ba875c132": "Windows에서는 마우스 오른쪽 버튼을 클릭하여 클립보드를 terminal 에 붙여넣습니다. 컨텍스트 메뉴를 열려면 Ctrl+오른쪽 클릭을 사용하세요.", + "8ba875c132": "Windows에서는 마우스 오른쪽 버튼을 클릭하여 클립보드를 terminal에 붙여넣습니다. 컨텍스트 메뉴를 열려면 Ctrl+오른쪽 클릭을 사용하세요.", "f0b8448570": "붙여넣으려면 마우스 오른쪽 버튼을 클릭하세요.", "04994f6929": "기본값", "fc564eadaf": "데비안", @@ -7754,14 +7754,14 @@ "1f402b3651": "WSL 배포", "d57f870938": "고급의", "4af2f7526e": "버전", - "d414022016": "으악", + "d414022016": "pwsh", "768613e483": "파워셸 7", "f9162f0b8e": "윈도우 파워셸", - "2d99cd91be": "파워셸", + "2d99cd91be": "powershell", "41a69bc24d": "PowerShell 셸 옵션이 새 terminal 패널에 대해 Windows PowerShell을 시작할지 아니면 PowerShell 7+를 시작할지 선택합니다.", "860e0e6402": "파워셸 버전", "07ec155fb6": "bash.exe", - "5a2db98d23": "세게 때리다", + "5a2db98d23": "bash", "591912177b": "자식 배쉬", "12519edb5d": "명령 프롬프트", "6cd20b9e64": "cmd", @@ -7865,11 +7865,11 @@ "colors_only": "Colors only", "no_themes_found": "No custom Warp themes found.", "builtin_themes_hint": "Warp's preloaded themes are part of the Warp app and can't be read from disk. Orca already includes most of them, like Dracula, Gruvbox, Solarized, and Tokyo Night.", - "custom_theme_yaml_hint": "사용자 지정 및 커뮤니티 테마는 자동 가져오기가 찾을 수 있도록 Warp themes 폴더에 YAML 파일로 있어야 합니다. Warp의 공개 테마 저장소를 클론했다면 Choose Folder를 사용해 해당 체크아웃을 가져오세요.", + "custom_theme_yaml_hint": "사용자 지정 및 커뮤니티 테마는 자동 가져오기가 찾을 수 있도록 Warp themes 폴더에 YAML 파일로 있어야 합니다. Warp의 공개 테마 repo를 클론했다면 Choose Folder를 사용해 해당 체크아웃을 가져오세요.", "choose_manually": "Choose a theme YAML file or folder to import manually.", "skipped_files": "Skipped files", "more_skipped_files": "{{value0}} more skipped files.", - "cancel": "Cancel", + "cancel": "취소", "import_theme_one": "Import 1 Theme", "import_theme_other": "Import {{value0}} Themes", "import_themes": "Import Themes" @@ -7895,18 +7895,18 @@ "707180d09c": "glab auth login", "4be0616873": "The GitLab CLI is installed but not authenticated. Run this command in a terminal:", "54a640af7a": "Install GitLab CLI", - "b56fd5676a": "Install the GitLab CLI to enable merge requests, issues, and pipelines.", + "b56fd5676a": "MR, 이슈 및 파이프라인을 활성화하려면 GitLab CLI를 설치하세요.", "faddeb763d": "GitLab CLI status is not available in this runtime yet.", "a47f71e357": "CLI.", "2a6b359e75": "glab", - "1f2b347bd3": "Merge requests, issues, todos, and pipelines via the", + "1f2b347bd3": "다음을 통해 MR, 이슈, 할 일, 파이프라인을 병합합니다.", "8d90249d22": "gh auth login", "2e44dda68a": "The GitHub CLI is installed but not authenticated. Run this command in a terminal:", "7755c28af5": "Install GitHub CLI", - "23cb5a0dee": "Install the GitHub CLI to enable pull requests, issues, and checks.", + "23cb5a0dee": "PR, 이슈 및 검사를 활성화하려면 GitHub CLI를 설치하세요.", "6f30fc4216": "GitHub CLI status is not available in this runtime yet.", "6b2cfb52b4": "gh", - "b4d900e7f1": "Pull requests, issues, and checks via the", + "b4d900e7f1": "PR, 이슈 및 검사는 다음을 통해 이루어집니다.", "account_scope_prefix": "Account scope" } } @@ -7933,10 +7933,10 @@ "6224fe9d34": "Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.", "1a12e33fe5": "Add Linear access", "622c224082": "Add workspace access", - "eae4a9f16b": "Add Linear access to browse and link issues.", + "eae4a9f16b": "이슈를 탐색하고 연결하려면 Linear 액세스를 추가하세요.", "fe9231215b": "Checking Linear access before showing setup actions.", "e1f5e6424c": "{{value0}} workspace{{value1}} connected", - "disconnect_all": "모두 연결 해제", + "disconnect_all": "연결 해제", "account_scope_prefix": "Account scope" } } @@ -8002,7 +8002,7 @@ "2b3c4d5e6f": "Apply to", "3c4d5e6f7a": "Overrides client default", "4d5e6f7a8b": "Inherits the client default", - "5e6f7a8b9c": "Reset" + "5e6f7a8b9c": "재설정" }, "ProviderHostScopeControl": { "scope_label": "{{value0}}: {{value1}}", @@ -8032,8 +8032,8 @@ "RepositoryForkSyncSection": { "defaultBranch": "기본 브랜치", "synced": "포크가 업데이트됨", - "syncedDescriptionSingular": "{{branch}} 브랜치를 1개 커밋만큼 fast-forward했습니다.", - "syncedDescriptionPlural": "{{branch}} 브랜치를 {{count}}개 커밋만큼 fast-forward했습니다.", + "syncedDescriptionSingular": "{{branch}} 브랜치를 1개 commit 만큼 fast-forward했습니다.", + "syncedDescriptionPlural": "{{branch}} 브랜치를 {{count}}개 commits 만큼 fast-forward했습니다.", "upToDate": "포크가 이미 최신 상태입니다", "upToDateDescription": "{{branch}} 브랜치가 이미 upstream과 일치합니다.", "missingOrigin": "origin 원격이 없습니다.", @@ -8041,13 +8041,13 @@ "upstreamMismatch": "upstream 원격이 더 이상 이 포크와 일치하지 않습니다.", "missingUpstreamBranch": "upstream 기본 브랜치를 확인할 수 없습니다.", "missingOriginBranch": "origin에 upstream 기본 브랜치가 없습니다.", - "diverged": "origin에 upstream에 없는 커밋이 있습니다.", + "diverged": "origin에 upstream에 없는 commits이 있습니다.", "blocked": "포크 동기화를 건너뜀", "blockedFallback": "Orca가 이 포크를 안전하게 fast-forward할 수 없습니다.", "failed": "포크 동기화 실패", "title": "포크를 최신 상태로 유지", "description": "이 포크를 upstream에서 안전하게 fast-forward합니다.", - "longDescription": "이 포크가 upstream보다 뒤처진 경우 Orca가 기본 브랜치를 안전하게 fast-forward할 수 있습니다. 브랜치에 로컬 전용 커밋이나 충돌이 있으면 Orca가 업데이트를 건너뜁니다.", + "longDescription": "이 포크가 upstream보다 뒤처진 경우 Orca가 기본 브랜치를 안전하게 fast-forward할 수 있습니다. 브랜치에 로컬 전용 commits 이나 충돌이 있으면 Orca가 업데이트를 건너뜁니다.", "forkOf": "{{owner}}/{{repo}}의 포크", "syncing": "동기화 중", "syncNow": "지금 동기화", @@ -8080,14 +8080,14 @@ "projectWsl": "This project runs in {{value0}} via WSL.", "inheritedWindows": "No project override. General settings select Windows.", "projectWindows": "This project runs on Windows.", - "liveTerminalSingular": "{{count}}개의 실행 중인 터미널", - "liveTerminalPlural": "{{count}}개의 실행 중인 터미널", + "liveTerminalSingular": "{{count}}개의 실행 중인 terminal", + "liveTerminalPlural": "{{count}}개의 실행 중인 terminals", "activeTaskSingular": "{{count}}개의 활성 작업", "activeTaskPlural": "{{count}}개의 활성 작업", "runtimeSessionJoin": "{{value0}} 및 {{value1}}", - "runtimeSessionWarning": "{{value0}}은 현재 런타임에서 계속 실행됩니다. 계속하기 전에 작업이 끝나게 하거나 터미널을 다시 시작하세요.", + "runtimeSessionWarning": "{{value0}}은 현재 런타임에서 계속 실행됩니다. 계속하기 전에 작업이 끝나게 하거나 terminals을 다시 시작하세요.", "pendingRuntimeChange": "Runtime change pending. New project work will use the selected runtime after you apply.", - "cancel": "Cancel", + "cancel": "취소", "applyRuntimeChange": "Apply runtime change" }, "PrivacyDiagnosticsRows": { @@ -8153,7 +8153,7 @@ "02b2ce911f": "설명(선택사항)", "1cd53359db": "설명", "68314b4369": "제목", - "694550a610": "기본", + "694550a610": "main", "0fad57a14c": "원격 브랜치를 검색하거나 브랜치 이름을 입력하세요.", "8584ccb43c": "기본 브랜치", "6f5f1962b6": "본점", @@ -8231,7 +8231,7 @@ "cf7cad58d2": "아직 commits이 없습니다.", "781a8bcf7b": "그래프 로드 중...", "d0fb0f4bf2": "commits 새로 고침", - "9f7535d22b": "Refs는 정확한 commit을 가리키는 브랜치 또는 태그 이름입니다. Git에 commit 에 대한 명명된 참조가 있는 경우에만 나타납니다.", + "9f7535d22b": "Refs는 정확한 commit을 가리키는 브랜치 또는 태그 이름입니다. Git에 commit에 대한 명명된 참조가 있는 경우에만 나타납니다.", "9289ba0cb9": "심판이란 무엇입니까?", "d836037d02": "Commits", "8232c8b2f2": "오픈 commit {{value0}}: {{value1}}", @@ -8239,7 +8239,7 @@ "62e685d5ec": "idle", "111e1d0db4": "오류", "e5e81e59a6": "commits 크기 조정", - "6d1e0a7c3b": "커밋 파일을 로드하지 못했습니다" + "6d1e0a7c3b": "commit 파일을 로드하지 못했습니다" }, "HostedReviewActions": { "4d5fb5a284": "닫기", @@ -8402,7 +8402,7 @@ "4f76c0a9de": "베이스 브랜치는 head 브랜치와 달라야 합니다.", "c5e4175139": "더 많은 {{value0}} 및 원격 작업", "78ddfd0bb4": "초안으로 만들기", - "e64a632456": "기본", + "e64a632456": "main", "6055949c50": "{{value0}} 기본 브랜치", "1f7119f604": "베이스", "9484270f45": "제목 및 설명 생성 중…", @@ -8422,7 +8422,7 @@ "e48caaf0dd": "충돌에 대한 AI agent를 시작했습니다.", "901140f47d": "agent를 시작하기 전에 전체 명령 입력을 리뷰하고 편집하세요.", "19652ddd76": "AI로 충돌 해결", - "c9ad22888e": "이 저장소의 브랜치 비교 대상을 선택하세요.", + "c9ad22888e": "이 repos의 브랜치 비교 대상을 선택하세요.", "574d2f4413": "메모 지우기", "05bb8f4a48": "취소", "48db37cca9": "모두 보기", @@ -8478,7 +8478,7 @@ "26511c22b4": "만드는 중...", "7a09d7f9d2": "베이스", "383cf92c73": "나무", - "d7ae61269b": "브랜치에 커밋됨", + "d7ae61269b": "브랜치에 Commit 됨", "48a003c1b1": "스테이징된 변경 사항", "d4ef4bafc5": "변경 사항", "522f44dce5": "추적되지 않은 파일", @@ -8515,31 +8515,31 @@ "d206117f90": "{{value0}} 충돌 ({{value1}})", "f3a8b2c1d0e5": "{{value0}} 제목을 입력하세요.", "e2b7a1c0d9f4": "{{value0}} 생성에 실패했습니다", - "hugeRepoIgnorePrompt": "이 저장소에 활성 변경 사항이 너무 많습니다. \"{{value0}}\"을(를) .gitignore에 추가하시겠습니까?", + "hugeRepoIgnorePrompt": "이 repos에 활성 변경 사항이 너무 많습니다. \"{{value0}}\"을(를) .gitignore에 추가하시겠습니까?", "hugeRepoIgnoreAction": ".gitignore에 추가", "tooManyChanges": "변경 사항이 너무 많이 감지되었습니다. 처음 {{value0}}개만 표시됩니다.", "bf5082de46": "{{value0}}이(가) 복사됨", "c06193ef57": "{{value0}}을(를) 복사하지 못했습니다", - "d172a4f068": "커밋 해시", - "e283b50179": "커밋 메시지", - "f394c6128a": "이 커밋을 설명할 에이전트가 없습니다", - "04a5d7239b": "이 저장소에는 지원되는 웹 원격이 없습니다", - "15b6e834ac": "브라우저에서 커밋을 열지 못했습니다", - "d37e68f61d": "검토를 위해 브랜치를 준비하는 중…", - "8d8f5c6c94": "커밋 메시지를 생성하는 중…", - "fda060d6ce": "커밋 메시지를 검토한 다음 Create PR을 다시 시도하세요.", - "b75cb1fd0c": "변경 사항을 커밋하는 중…", + "d172a4f068": "Commit 해시", + "e283b50179": "Commit 메시지", + "f394c6128a": "이 commit을 설명할 agent가 없습니다", + "04a5d7239b": "이 repos에는 지원되는 웹 원격이 없습니다", + "15b6e834ac": "브라우저에서 commit을 열지 못했습니다", + "d37e68f61d": "리뷰를 위해 브랜치를 준비하는 중…", + "8d8f5c6c94": "commit 메시지를 생성하는 중…", + "fda060d6ce": "commit 메시지를 검토한 다음 Create PR을 다시 시도하세요.", + "b75cb1fd0c": "변경 사항을 Commit 하는 중…", "995c5e67ec": "검토 설정에 확인이 필요합니다.", "d7492cafce": "Source Control을 새로 고칠 수 없습니다. Create PR을 다시 시도하세요.", "473f18758e": "Source Control AI 설정", - "createPrIntentConfigureAi": "커밋 메시지를 추가하거나 Source Control AI 설정을 구성하세요.", - "createPrIntentGenerateFailed": "커밋 메시지를 생성할 수 없습니다. 메시지를 추가한 다음 다시 시도하세요.", - "createPrIntentCommitFailed": "변경 사항을 커밋할 수 없습니다. 문제를 수정한 다음 Create PR을 다시 시도하세요.", + "createPrIntentConfigureAi": "commit 메시지를 추가하거나 Source Control AI 설정을 구성하세요.", + "createPrIntentGenerateFailed": "commit 메시지를 생성할 수 없습니다. 메시지를 추가한 다음 다시 시도하세요.", + "createPrIntentCommitFailed": "변경 사항을 commit 할 수 없습니다. 문제를 수정한 다음 Create PR을 다시 시도하세요.", "createPrIntentNeedsSync": "리뷰를 만들기 전에 이 브랜치를 동기화하세요.", "createPrIntentBranchNotReady": "브랜치가 아직 리뷰를 만들 준비가 되지 않았습니다.", "createPrIntentPublishing": "브랜치를 게시하는 중…", "createPrIntentForcePushing": "lease로 강제 푸시하는 중…", - "createPrIntentPushing": "커밋을 푸시하는 중…", + "createPrIntentPushing": "commits을 푸시하는 중…", "createPrIntentRemoteFailed": "원격 브랜치를 업데이트할 수 없습니다. Create PR을 다시 시도하세요.", "createPrIntentGeneratingDetails": "리뷰 세부 정보 생성 중…", "createPrIntentBranchChangedDuringDetails": "리뷰 세부 정보를 생성하는 동안 브랜치가 변경되었습니다. Create PR을 다시 시도하세요.", @@ -8551,16 +8551,16 @@ "b3c8f1a902": "파일 이름으로 필터링", "d4f8c2a901": "필터 지우고 닫기", "e8a1c4b203": "vs", - "f9b2441bb6": "{{value0}}보다 1 커밋 앞서 있음", - "b715ef615b": "{{value1}}보다 {{value0}} 커밋 앞서 있음", - "c1a8f3e204": "{{value0}}보다 1 커밋 뒤처짐", - "d2b9g4f315": "{{value1}}보다 {{value0}} 커밋 뒤처짐", + "f9b2441bb6": "{{value0}}보다 1 commit 앞서 있음", + "b715ef615b": "{{value1}}보다 {{value0}} commits 앞서 있음", + "c1a8f3e204": "{{value0}}보다 1 commit 뒤처짐", + "d2b9g4f315": "{{value1}}보다 {{value0}} commits 뒤처짐", "e9c2a5d416": "{{value0}}와 동일한 상태" }, "SourceControlAgentActionDialog": { "8e856842d1": "선택한 agent를 시작할 수 없습니다.", "c075d00de1": "워크스페이스 연결을 확인할 수 없습니다.", - "808cfe0a3b": "이 저장소에만 저장", + "808cfe0a3b": "이 repos에만 저장", "994cddd1f7": "저장하지 않음", "38b899cc02": "모든 리포지토리" }, @@ -8583,15 +8583,15 @@ "6cefcdfba1": "You can change it later in Source Control AI settings.", "c29f9cf266": "Save this prompt and don't show this review next time", "d8f40128ee": "{basePrompt} is Orca's default prompt.", - "ea4788705e": "Cancel", + "ea4788705e": "취소", "b0da3a4d3e": "실행 레시피가 이미 저장되었습니다", "bff4795a6d": "저장된 레시피를 업데이트하려면 agent, 인수 또는 프롬프트 템플릿을 변경하세요.", "5c75b24735": "Orca가 시작하기 전에 agent가 받을 내용을 사용자 지정합니다." }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "전역 기본값으로 저장", - "7f1ec309a4": "모든 저장소에 대해 기본값으로 저장", - "5959da1e4d": "이 저장소에만 저장", + "7f1ec309a4": "모든 repos에 대해 기본값으로 저장", + "5959da1e4d": "이 repos에만 저장", "d054d5e0a0": "설정이 로드되지 않았습니다." }, "SourceControlTextGenerationDialogForm": { @@ -8837,16 +8837,16 @@ "18a0fca877": "모두 스테이지", "f01f16d77f": "commit 하려면 commit 메시지를 입력하세요.", "ab41fb926b": "단계적 변경 Commit", - "2d8f185fbc": "부분적으로 준비된 파일을 커밋하기 전에 모든 변경 사항을 준비합니다.", - "a6457b46a7": "커밋하기 전에 충돌을 해결하세요.", + "2d8f185fbc": "부분적으로 준비된 파일을 commit 하기 전에 모든 변경 사항을 준비합니다.", + "a6457b46a7": "commit 하기 전에 충돌을 해결하세요.", "484f45c439": "{{value0}} 진행 중…", "74fc171e99": "강제 푸시 진행 중…", "16aee3a5c1": "Commit 진행 중…", - "e61b0d7a3c": "커밋을 게시하기 전에 브랜치를 체크아웃하세요.", + "e61b0d7a3c": "commits을 게시하기 전에 브랜치를 체크아웃하세요.", "1d47e850cf": "연결된 검토 브랜치에 업데이트 푸시", "c39d0c75c3": "연결된 검토 브랜치 대상을 사용할 수 없습니다.", "8c6d15a07d": "PR 생성", - "d37e68f61d": "검토를 위해 브랜치를 준비하는 중…", + "d37e68f61d": "리뷰를 위해 브랜치를 준비하는 중…", "c72e5e65d1": "이 브랜치를 준비하고 {{value0}} 생성", "b8e4f2a901": "Wait for the remote operation to finish.", "c9f3a1b802": "Resolve conflicts before creating a {{value0}}.", @@ -8894,7 +8894,7 @@ "8adb953095": "작업 실패" }, "GitHistoryGraphSvg": { - "47eff48230": "머리" + "47eff48230": "HEAD" }, "create": { "pull": { @@ -8926,7 +8926,7 @@ "noSessionsMatchFilters": "No sessions match the current filters", "sessionId": "세션 ID", "logPath": "로그 경로", - "agents": "에이전트", + "agents": "Agents", "sessionsShownCompact": "{{value0}} 표시됨" }, "AiVaultPanelControls": { @@ -8951,7 +8951,7 @@ "agent": "Agent", "resetView": "Reset view", "hideEmptySessions": "Hide empty sessions", - "workspaceScope": "Workspace", + "workspaceScope": "워크스페이스", "worktreeScope": "Worktree", "globalScope": "Global" }, @@ -9022,17 +9022,17 @@ }, "GitHistoryCommitFiles": { "a1b2c3d4e5": "파일 로드 중…", - "b2c3d4e5f6": "이 커밋에는 파일 변경 사항이 없습니다", + "b2c3d4e5f6": "이 commit에는 파일 변경 사항이 없습니다", "c3d4e5f6a7": "모든 변경 사항을 함께 열기" }, "GitHistoryRow": { - "2f9c41ab07": "커밋 {{value0}}의 파일 표시: {{value1}}", - "4a8d9e0c1f": "커밋 {{value0}}의 파일 숨기기: {{value1}}" + "2f9c41ab07": "commit {{value0}}의 파일 표시: {{value1}}", + "4a8d9e0c1f": "commit {{value0}}의 파일 숨기기: {{value1}}" }, "GitHistoryCommitContextMenu": { - "7b1c4e9a02": "브라우저에서 커밋 열기", - "8c2d5fab13": "커밋 해시 복사", - "9d3e60bc24": "커밋 메시지 복사", + "7b1c4e9a02": "브라우저에서 commit 열기", + "8c2d5fab13": "commit 해시 복사", + "9d3e60bc24": "commit 메시지 복사", "ae4f71cd35": "변경 사항 설명" }, "pull": { @@ -9063,7 +9063,7 @@ "929734aea5": "모두 선택 취소" }, "NestedRepoScanLimitNotice": { - "642a43c139": "중첩된 저장소 검사 한도", + "642a43c139": "중첩된 repos 검사 한도", "574eb5408b": "부분 스캔 결과를 표시합니다.", "03e9beab7b": "스캔이 일찍 중지되었습니다." }, @@ -9079,7 +9079,7 @@ "icon": { "0ad395d475": "상자", "857977b901": "모양", - "b1b8d99fc4": "일체 포함", + "b1b8d99fc4": "AI", "137bdb1856": "측정항목", "d202c659a3": "설계", "c4fd14299d": "회사", @@ -9199,9 +9199,9 @@ "a5e5da02f7": "연동", "35bbaf5ae0": "알림", "984338477a": "테마", - "c47e1bd149": "에이전트", - "windowsTerminalTitle": "Windows 터미널 기본값 설정", - "windowsTerminalSubtitle": "새 창에서 사용할 기본 셸과 터미널에서 오른쪽 클릭의 동작을 선택하세요." + "c47e1bd149": "agent", + "windowsTerminalTitle": "Windows terminal 기본값 설정", + "windowsTerminalSubtitle": "새 창에서 사용할 기본 셸과 terminal에서 오른쪽 클릭의 동작을 선택하세요." }, "OnboardingFooter": { "ba58547306": "뒤로", @@ -9219,7 +9219,7 @@ "60c5576353": "투어 종료" }, "RepoStep": { - "e8fdb36338": "저장소를 스캔하는 중입니다. 중지하려면 클릭하세요.", + "e8fdb36338": "repos를 스캔하는 중입니다. 중지하려면 클릭하세요.", "b7c4da0504": "SSH? 설정에서 호스트를 설정하세요", "c33b190ca3": "서버 경로만", "7b679207e4": "워크스페이스", @@ -9236,7 +9236,7 @@ "2ebbc26343": "/home/user/project", "466108ab89": "런타임 서버에 존재하는 경로를 입력하십시오.", "8cab104e3c": "서버 프로젝트 열기", - "2d20200346": "저장소 가져오기", + "2d20200346": "repos 가져오기", "27ca610db1": "뒤로", "cecd6593fa": "스캔한 폴더:", "c7af322fc3": "스캔 중지", @@ -9286,26 +9286,26 @@ "powerShellPwsh": "사용할 수 있으면 PowerShell 7+를 사용하고, 없으면 Windows PowerShell을 사용합니다.", "powerShellInbox": "지원되는 모든 Windows 설치에 포함된 Windows PowerShell을 사용합니다.", "commandPrompt": "명령 프롬프트", - "commandPromptDescription": "기존 cmd.exe 동작으로 새 터미널 창을 엽니다.", + "commandPromptDescription": "기존 cmd.exe 동작으로 새 terminal 창을 엽니다.", "gitBash": "Git Bash", "gitBashDescription": "Unix 스타일 셸 작업에는 Git for Windows의 bash.exe를 사용합니다.", "gitBashUnavailable": "선택되었지만 이 컴퓨터에서 Git Bash를 찾지 못했습니다.", "wsl": "WSL", - "wslDescription": "Windows Subsystem for Linux의 기본 환경에서 새 터미널 창을 시작합니다.", + "wslDescription": "Windows Subsystem for Linux의 기본 환경에서 새 terminal 창을 시작합니다.", "wslUnavailable": "선택되었지만 이 컴퓨터에서 WSL을 찾지 못했습니다.", "rightClickPaste": "오른쪽 클릭으로 붙여넣기", "rightClickPasteDescription": "오른쪽 클릭하면 클립보드가 붙여넣어집니다. Ctrl+오른쪽 클릭은 컨텍스트 메뉴를 엽니다.", "rightClickMenu": "컨텍스트 메뉴 열기", - "rightClickMenuDescription": "오른쪽 클릭하면 터미널 메뉴가 열립니다. 메뉴나 키보드로 붙여넣습니다.", - "loading": "터미널 설정을 불러오는 중...", + "rightClickMenuDescription": "오른쪽 클릭하면 terminal 메뉴가 열립니다. 메뉴나 키보드로 붙여넣습니다.", + "loading": "terminal 설정을 불러오는 중...", "defaultShell": "기본 셸", - "defaultShellDescription": "Orca가 Windows 터미널의 새 창에서 열 셸을 선택하세요.", + "defaultShellDescription": "Orca가 Windows terminal의 새 창에서 열 셸을 선택하세요.", "wslDistribution": "WSL 배포판", "wslDistributionDescription": "Windows 기본 배포판을 사용하거나 설치된 특정 배포판을 선택하세요.", "loadingDistros": "배포판을 불러오는 중", "windowsDefault": "Windows 기본값", "rightClickBehavior": "오른쪽 클릭 동작", - "rightClickBehaviorDescription": "Windows에서 익숙한 터미널 마우스 동작을 선택하세요." + "rightClickBehaviorDescription": "Windows에서 익숙한 terminal 마우스 동작을 선택하세요." } }, "new": { @@ -9345,7 +9345,7 @@ "differentTaskSource": ", 선택한 작업 소스와 다릅니다.", "currentTaskSource": "현재 작업 소스", "placeholderNameOrLinearUrl": "이름 또는 Linear URL 입력", - "placeholderWorkspaceName": "작업 영역 이름 입력", + "placeholderWorkspaceName": "워크스페이스 이름 입력", "placeholderSmartWithBranchGitLabLinear": "이름, #1234, 브랜치, GitHub/GitLab 또는 Linear URL 입력", "placeholderSmartGitLabLinear": "이름, #1234, GitHub/GitLab 또는 Linear URL 입력", "placeholderSmartWithBranchGitLab": "이름, #1234, 브랜치, GitHub 또는 GitLab URL 입력", @@ -9355,7 +9355,7 @@ "searchGitLab": "GitLab MR 및 이슈 검색", "searchBranches": "브랜치 검색", "searchLinear": "Linear 이슈 검색", - "workspaceName": "작업 영역 이름" + "workspaceName": "워크스페이스 이름" }, "ProjectHostSetupCombobox": { "empty": "No hosts are ready for this project.", @@ -9389,7 +9389,7 @@ "3241f3c26a": "QR 설치", "7af266b80d": "QR코드 설치", "aa97420ba4": "설치 링크 복사", - "ac1eb64952": "기계적 인조 인간", + "ac1eb64952": "Android", "711e6f4b47": "iOS", "e75647ace0": "휴대폰으로 QR을 스캔하거나 설치 링크를 열어 Orca Mobile을 다운로드하세요.", "0d9b33299e": "앱을 받으세요.", @@ -9454,7 +9454,7 @@ "d047197480": "GitHub · Linear", "a4c3f7b7aa": "작업", "d33d7a9c29": "orca  ·  feat/mobile-page", - "25d6e8a491": "feat/모바일 페이지", + "25d6e8a491": "feat/mobile-page", "c791677f2f": "재개", "cf3f98fa3f": "연결이 끊김", "091355da3d": "M1 미니 · 집", @@ -9485,7 +9485,7 @@ "1d448b69f7": "통과", "d39445686a": "src/transport/host-store.test.ts", "a6e7cdc688": "pnpm 테스트 - 모바일 필터링", - "21b67dfc92": "세게 때리다", + "21b67dfc92": "Bash", "d6d1041a1c": "⎿ 페어 스캔 슬라이드를 terminal 세션으로 대체했습니다.", "336c0e070e": "mobile/orca-mobile-sidebar-mock-v3.html", "6d4ebd5833": "편집", @@ -9499,7 +9499,7 @@ "606aa93192": "파일", "94febb0976": "소스 제어", "8d6516312d": "2개 terminals · claude active", - "8432787c4e": "feat/모바일 페이지", + "8432787c4e": "feat/mobile-page", "8fd998acd3": "뒤로" }, "WorktreeListSlide": { @@ -9650,7 +9650,7 @@ "79445f7512": "내 앱에서 메모 승인", "99a8624bcb": ">", "2adb561b44": "Claude Code 세션이 시작되었습니다.", - "94787f01f8": "Claude 코드", + "94787f01f8": "Claude Code", "9cddfe96b2": "로컬 앱", "9634d870d1": "승인", "3cc2df3671": "완료", @@ -9737,7 +9737,7 @@ "13294d3405": "완료" }, "FeatureWallSetupWorkflowActions": { - "486c2f4d8d": "먼저 git 프로젝트를 추가한 다음 해당 저장소에 대한 설정 스크립트를 구성하세요.", + "486c2f4d8d": "먼저 git 프로젝트를 추가한 다음 해당 repos에 대한 설정 스크립트를 구성하세요.", "00078a6134": "설정에서 보기", "14327073cc": "저장", "88469e926b": "설정 스크립트", @@ -9758,7 +9758,7 @@ }, "ReviewNotesAnimatedVisual": { "5dbd27c4c2": "Codex", - "09094f25e2": "Claude 코드", + "09094f25e2": "Claude Code", "294aaff104": "메모 보내기", "ea4e45b71b": "메모 추가", "271ea0cbf3": "취소", @@ -9796,7 +9796,7 @@ "3774b80eae": "설명", "07da9245cc": "PR 제목", "54a093c52d": "제목", - "3b9b96d6a6": "기본", + "3b9b96d6a6": "main", "ce7d5d3a18": "기본 브랜치", "e4473d438f": "AI로 생성", "c30cd930ff": "PR 생성", @@ -9959,7 +9959,7 @@ "c9728107c5": "사용해 보기", "25dd101f15": "브라우저 설정에 주의가 필요합니다", "e02b11e6b0": "브라우저 설정이 준비되었습니다", - "d6d15077df": "스킬 명령을 복사해 아래에 삽입해 검토할 수 있습니다.", + "d6d15077df": "리뷰를 위해 스킬 명령을 아래에 복사하여 삽입했습니다.", "78e65f19d9": "브라우저 설정에 실패했습니다", "b7345c18db": "예기치 않은 오류가 발생했습니다.", "5f97caf76b": "설치 중…", @@ -9983,9 +9983,9 @@ "integration": { "step": { "0f47ff17c6": "Change", - "5538eb6743": "Done", + "5538eb6743": "완료", "open_step": "Open", - "close_step": "Close" + "close_step": "닫기" } } }, @@ -10007,7 +10007,7 @@ "22e62f3bab": "Claude Code 세션이 시작되었습니다." }, "CliSkillSetupTerminal": { - "1953e90447": "Enter를 눌러 agents 에 대한 Orca CLI 오케스트레이션 스킬을 설치하십시오.", + "1953e90447": "Enter를 눌러 agents에 대한 Orca CLI 오케스트레이션 스킬을 설치하십시오.", "43b60ec5c3": "Orca CLI 및 오케스트레이션 스킬 설치 terminal", "84e9576dac": "스킬 설정", "5c3aee22c0": "명령 복사", @@ -10170,7 +10170,7 @@ }, "editor": { "ChangesModeView": { - "ef25ae2d09": "커밋되지 않은 변경 사항이 없습니다.", + "ef25ae2d09": "commit 되지 않은 변경 사항이 없습니다.", "052c184f24": "이 파일에는 텍스트 비교를 사용할 수 없습니다.", "7dffb0f563": "바이너리 파일", "54e0035b15": "차이점 로드 중..." @@ -10215,7 +10215,7 @@ "f786fd54e1": "인라인", "ea08dae15b": "모두 축소", "19c45cfdc0": "모두 확장", - "982d14bfa5": "커밋되지 않은 Diff 열기", + "982d14bfa5": "commit 되지 않은 Diff 열기", "3d909843bb": "오픈 브랜치 차이점", "8368d256ec": "결합 브랜치", "8f68ad9ca9": "{{value0}} AI {{value1}} 표시", @@ -10316,7 +10316,7 @@ "EditorViewToggle": { "b3410cd5e0": "공책", "e408aa9cd5": "테이블", - "167f45888c": "커밋되지 않은 변경 사항", + "167f45888c": "commit 되지 않은 변경 사항", "4837f3f578": "변경 사항", "ac3bb87913": "편집", "0d193dc03c": "시사", @@ -10459,27 +10459,27 @@ "c72beafc0f": "코드 복사", "74eab1d9b2": "YAML", "5ef5605cb7": "XML", - "88d777bc07": "타입스크립트", - "9e384d48dc": "스위프트", + "88d777bc07": "TypeScript", + "9e384d48dc": "Swift", "3009f722b9": "SQL", "d01f55be57": "껍데기", "5af8251002": "SCSS", - "e72e6b03f4": "녹", - "96182a2f64": "루비", - "2391f9cda9": "파이썬", - "89d6cc14fb": "인어", + "e72e6b03f4": "Rust", + "96182a2f64": "Ruby", + "2391f9cda9": "Python", + "89d6cc14fb": "Mermaid", "983b9576b4": "Markdown", - "bcb236e2d8": "코틀린", + "bcb236e2d8": "Kotlin", "78eba32de4": "JSON", "a209c57063": "자바스크립트", - "36536ad539": "자바", + "36536ad539": "Java", "8c4a3fa02d": "HTML", "706fd85738": "GraphQL", - "edfcc64182": "가다", + "edfcc64182": "Go", "bf6ee5caaa": "차이점", "026653f21f": "CSS", "4daed43ae3": "C++", - "4227cf50fe": "세게 때리다", + "4227cf50fe": "Bash", "13822cdfda": "일반 텍스트" }, "RichMarkdownDocLinkMenu": { @@ -10652,18 +10652,18 @@ "593f2193f6": "이 초안은 안전한 표시 한도를 초과했지만 계속 저장할 수 있습니다." }, "CheckRunDetailsPanel": { - "8f2d0f5a91": "Passed", + "8f2d0f5a91": "통과", "4c8e1b2d73": "Failed", "91a4c7e2b0": "Cancelled", "2f6d8a1c45": "Timed out", "7b3e9d4f12": "Skipped", "5a1c8e3d67": "Neutral", "3d9f2b8e14": "Pending", - "b7f5e2c91a": "Refresh", + "b7f5e2c91a": "새로고침", "a54ae21c6f": "Status:", "fd46a70f1a": "Started", "00e1c1658a": "Completed", - "aa8494ae3c": "check #", + "aa8494ae3c": "체크 #", "2dd5ddabc4": "workflow #", "1f2b980522": "Loading check details…", "d098e5529a": "Output", @@ -10856,10 +10856,10 @@ "77351d22f5": "브라우저 설정", "e0e125e074": "파일에서…", "0c6d254eca": "{{value0}}에서", - "244266c122": "수입…", + "244266c122": "가져오기…", "e52a955e6f": "언제든지 설정 > 브라우저에서 찾을 수 있습니다.", "4f5ffaa6a1": "브라우저 데이터 가져오기", - "b24fef25be": "수입", + "b24fef25be": "가져오기", "02e89014c5": "{{value1}}{{value2}}에서 {{value0}} 쿠키를 가져왔습니다.", "d40d584769": "파일에서 {{value0}}개의 쿠키를 가져왔습니다." }, @@ -10927,7 +10927,7 @@ "168350ae6a": "요소를 클릭하거나 마우스로 가리킨 다음 C를 눌러 복사하거나 S를 눌러 스크린샷을 찍습니다.", "e852e20cea": "복사됨 - S를 눌러 스크린샷을 찍거나 다른 요소를 선택하세요.", "a5dcd0fd1d": "확인", - "777b5bc4ec": "agent 에 대한 피드백을 추가하려면 요소를 클릭하세요.", + "777b5bc4ec": "agent에 대한 피드백을 추가하려면 요소를 클릭하세요.", "b733a91bd9": "선택한 요소에 대한 피드백을 추가합니다.", "4328a0a062": "가져오기 실패: {{value0}}", "26615e116b": "오류", @@ -11262,7 +11262,7 @@ "repoHealth": { "category": "Repo 건강", "name": "평일 repo 감사", - "prompt": "저장소 상태를 리뷰합니다. 종속성 업데이트, 실패한 테스트, lint/typecheck 상태, 위험한 공개 변경 사항을 확인하세요. 조사 결과를 요약하고 다음 조치를 제안합니다." + "prompt": "repos 상태를 리뷰합니다. 종속성 업데이트, 실패한 테스트, lint/typecheck 상태, 위험한 공개 변경 사항을 확인하세요. 조사 결과를 요약하고 다음 조치를 제안합니다." }, "releasePrep": { "category": "출시 준비", @@ -11317,7 +11317,7 @@ "cf780197a1": "활동을 보려면 agent를 선택하세요.", "e3db9892f6": "아직 활동이 없습니다.", "1b633f5c1e": "terminal 연결 중...", - "8de7c5beaa": "Terminal이용 불가", + "8de7c5beaa": "Terminal 이용 불가", "866083500b": "드래그하여 크기 조정", "443690186e": "활동 스레드 목록 크기 조정", "7cd632006b": "이 필터와 일치하는 agent 활동이 없습니다.", @@ -11355,9 +11355,9 @@ "jira": { "connect": { "dialog": { - "63ce735809": "Connect", + "63ce735809": "연결", "4a2ab52781": "Verifying…", - "79e7aaed39": "Cancel", + "79e7aaed39": "취소", "fdd26d81cc": "Atlassian account settings", "8090504a3e": "Create a token in", "7b3967c12f": "Atlassian API token", @@ -11390,7 +11390,15 @@ "openReviewExternally": "{{value0}} 외부에서 열기", "summary": "{{value0}}개 연결됨 · PR/MR 있음 {{value1}}개 · 주의 필요 {{value2}}개 · 대기 중 {{value3}}개 · 통과 {{value4}}개 · PR 없음 {{value5}}개 · 알 수 없음 {{value6}}개", "showDetails": "{{value0}} PR 검사 세부 정보 표시", - "hideDetails": "{{value0}} PR 검사 세부 정보 숨기기" + "hideDetails": "{{value0}} PR 검사 세부 정보 숨기기", + "reviewChecks": "Review checks", + "allChecksPassing": "all checks passing", + "oneWorktree": "1 worktree", + "worktreeCount": "{{value0}} worktrees", + "oneFailing": "1 failing", + "failingCount": "{{value0}} failing", + "onePending": "1 pending", + "pendingCount": "{{value0}} pending" }, "parentPrChecks": { "rowSummary": { @@ -11427,10 +11435,10 @@ "routing": { "preference": { "dialog": { - "badge": "터미널 링크", + "badge": "Terminal 링크", "preview": "미리보기", - "title": "터미널 링크를 Orca 브라우저에서 열까요?", - "description": "터미널 링크를 Orca 브라우저에서 열거나 시스템 브라우저를 계속 사용합니다.", + "title": "terminal 링크를 Orca 브라우저에서 열까요?", + "description": "terminal 링크를 Orca 브라우저에서 열거나 시스템 브라우저를 계속 사용합니다.", "orca": { "button": "Orca에서 열기", "note": "Orca는 가져온 쿠키를 사용해 로그인된 사이트를 열 수 있습니다." @@ -11451,7 +11459,7 @@ } }, "keep": { - "title": "터미널 링크를 Orca 브라우저에서 계속 열까요?", + "title": "terminal 링크를 Orca 브라우저에서 계속 열까요?", "description": "또는 시스템 브라우저를 기본값으로 사용합니다.", "orca": { "button": "Orca에서 계속 열기" @@ -11481,7 +11489,7 @@ "noMatchingGitHubWorkTitle": "No matching GitHub work", "changeQueryDescription": "Change the query or clear it.", "noGitLabIssuesTitle": "No GitLab issues", - "noGitLabIssuesDescription": "No GitLab issues match this filter.", + "noGitLabIssuesDescription": "이 필터와 일치하는 GitLab 이슈가 없습니다.", "noGitLabMrsTitle": "No GitLab merge requests", "noGitLabMrsDescription": "No GitLab MRs match this filter.", "noGitLabWorkTitle": "No GitLab work", @@ -11506,7 +11514,7 @@ "starOnGithub": "Star on GitHub", "onboardingCompleted": "Onboarding completed!", "body": "If you’re enjoying Orca so far, a GitHub star helps other developers discover it.", - "dismiss": "Dismiss", + "dismiss": "닫기", "later": "Later" } } @@ -11516,11 +11524,11 @@ "hostedReview": { "copy": { "f0a4b8c2d1": "PR", - "e9f3a7b1c0": "풀 리퀘스트", - "d8e2f6a0b9": "풀 리퀘스트", + "e9f3a7b1c0": "PR", + "d8e2f6a0b9": "PR", "c7d1e5f9a8": "GitHub", "c4e8f1a2b9": "MR", - "b3d7e0f1a8": "머지 리퀘스트", + "b3d7e0f1a8": "MR", "a2c6d9e0f7": "머지 리퀘스트", "91b5c8d7e6": "GitLab" } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 43528448567..5eceb5d9664 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11390,7 +11390,15 @@ "openReviewExternally": "在外部打开 {{value0}}", "summary": "已附加 {{value0}} 个 · 有 PR/MR {{value1}} 个 · 需注意 {{value2}} 个 · 等待中 {{value3}} 个 · 通过 {{value4}} 个 · 无 PR {{value5}} 个 · 未知 {{value6}} 个", "showDetails": "显示 {{value0}} 的 PR 检查详情", - "hideDetails": "隐藏 {{value0}} 的 PR 检查详情" + "hideDetails": "隐藏 {{value0}} 的 PR 检查详情", + "reviewChecks": "Review checks", + "allChecksPassing": "all checks passing", + "oneWorktree": "1 worktree", + "worktreeCount": "{{value0}} worktrees", + "oneFailing": "1 failing", + "failingCount": "{{value0}} failing", + "onePending": "1 pending", + "pendingCount": "{{value0}} pending" }, "parentPrChecks": { "rowSummary": { From fc17631af3d9ba7bafe98ba1ac0ce5dc7e0e1963 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:30:23 -0700 Subject: [PATCH 006/163] fix: preserve Jira description formatting (#5762) Co-authored-by: Orca --- src/main/jira/adf-markdown.ts | 192 ++++++++++++++++++ src/main/jira/issues.test.ts | 101 +++++++++ src/main/jira/issues.ts | 66 +----- .../src/components/JiraIssueWorkspace.tsx | 1 + 4 files changed, 297 insertions(+), 63 deletions(-) create mode 100644 src/main/jira/adf-markdown.ts diff --git a/src/main/jira/adf-markdown.ts b/src/main/jira/adf-markdown.ts new file mode 100644 index 00000000000..2c4eea30ec2 --- /dev/null +++ b/src/main/jira/adf-markdown.ts @@ -0,0 +1,192 @@ +type JiraAdfRecord = Record + +type MarkdownBlock = { + kind: 'block' | 'list' + text: string +} + +function asRecord(value: unknown): JiraAdfRecord { + return value && typeof value === 'object' ? (value as JiraAdfRecord) : {} +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function textNode(text: string): JiraAdfRecord { + return text ? { type: 'text', text } : { type: 'hardBreak' } +} + +export function textToAdf(text: string): JiraAdfRecord { + const lines = text.split(/\r?\n/) + return { + type: 'doc', + version: 1, + content: lines.map((line) => ({ + type: 'paragraph', + content: line ? [textNode(line)] : [] + })) + } +} + +function positiveInteger(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback +} + +function headingLevel(value: unknown): number { + return Math.min(Math.max(positiveInteger(value, 1), 1), 6) +} + +function renderInline(node: unknown): string { + if (!node) { + return '' + } + if (typeof node === 'string') { + return node + } + if (Array.isArray(node)) { + return node.map(renderInline).join('') + } + if (typeof node !== 'object') { + return '' + } + + const record = node as JiraAdfRecord + if (typeof record.text === 'string') { + return record.text + } + if (record.type === 'hardBreak') { + return '\n' + } + + const attrs = asRecord(record.attrs) + const fallbackText = asString(attrs.text) || asString(attrs.shortName) || asString(attrs.url) + if (fallbackText) { + return fallbackText + } + + return renderInline(record.content) +} + +function joinBlocks(blocks: MarkdownBlock[]): string { + return blocks + .map((block) => block.text) + .filter((text) => text.length > 0) + .join('\n\n') +} + +function renderBlocks(content: unknown): MarkdownBlock[] { + return asArray(content) + .map(renderBlock) + .filter((block) => block.text.length > 0) +} + +function renderListItem(node: unknown, prefix: string): string { + const blocks = renderBlocks(asRecord(node).content) + if (blocks.length === 0) { + return prefix.trimEnd() + } + + const lines: string[] = [] + const continuationIndent = ' '.repeat(prefix.length) + blocks.forEach((block, blockIndex) => { + const blockLines = block.text.split('\n') + if (blockIndex === 0) { + lines.push(`${prefix}${blockLines[0] ?? ''}`.trimEnd()) + blockLines.slice(1).forEach((line) => { + lines.push(`${continuationIndent}${line}`.trimEnd()) + }) + return + } + + if (block.kind !== 'list') { + lines.push('') + } + blockLines.forEach((line) => { + lines.push(`${continuationIndent}${line}`.trimEnd()) + }) + }) + + return lines.join('\n') +} + +function renderList(record: JiraAdfRecord, ordered: boolean): string { + const start = ordered ? positiveInteger(asRecord(record.attrs).order, 1) : 1 + return asArray(record.content) + .map((item, index) => renderListItem(item, ordered ? `${start + index}. ` : '- ')) + .join('\n') +} + +function renderCodeBlock(record: JiraAdfRecord): MarkdownBlock { + const text = renderInline(record.content).replace(/\n$/, '') + return { kind: 'block', text: ['```', text, '```'].join('\n') } +} + +function renderBlockquote(record: JiraAdfRecord): MarkdownBlock { + const text = joinBlocks(renderBlocks(record.content)) + return { + kind: 'block', + text: text + .split('\n') + .map((line) => `> ${line}`.trimEnd()) + .join('\n') + } +} + +function renderBlock(node: unknown): MarkdownBlock { + if (typeof node === 'string') { + return { kind: 'block', text: node } + } + if (Array.isArray(node)) { + return { kind: 'block', text: joinBlocks(renderBlocks(node)) } + } + if (!node || typeof node !== 'object') { + return { kind: 'block', text: '' } + } + + const record = node as JiraAdfRecord + const type = asString(record.type) + if (type === 'doc') { + return { kind: 'block', text: joinBlocks(renderBlocks(record.content)) } + } + if (type === 'paragraph') { + return { kind: 'block', text: renderInline(record.content) } + } + if (type === 'heading') { + const prefix = '#'.repeat(headingLevel(asRecord(record.attrs).level)) + return { kind: 'block', text: `${prefix} ${renderInline(record.content).trim()}`.trim() } + } + if (type === 'bulletList') { + // Why: Orca renders Jira bodies as Markdown, so ADF list containers need + // concrete list markers instead of newline-only flattened text. + return { kind: 'list', text: renderList(record, false) } + } + if (type === 'orderedList') { + return { kind: 'list', text: renderList(record, true) } + } + if (type === 'listItem') { + return { kind: 'list', text: renderListItem(record, '- ') } + } + if (type === 'codeBlock') { + return renderCodeBlock(record) + } + if (type === 'blockquote') { + return renderBlockquote(record) + } + if (type === 'rule') { + return { kind: 'block', text: '---' } + } + + return { kind: 'block', text: joinBlocks(renderBlocks(record.content)) || renderInline(record) } +} + +export function adfToMarkdownText(value: unknown): string { + return renderBlock(value) + .text.replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 7225a0e3ebd..befbbeed95c 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -199,6 +199,107 @@ describe('Jira issue operations', () => { }) }) + it('maps Jira ADF descriptions into Markdown blocks and lists', async () => { + const { mapJiraIssue } = await import('./issues') + + const issue = mapJiraIssue(makeEntry().site, { + id: 'issue-33', + key: 'PM-33', + fields: { + summary: 'BE - Tests E2E/Cleanup', + description: { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'História' }, + { type: 'hardBreak' }, + { type: 'text', text: 'Coverage ownership' } + ] + }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'admin - JOAO' }] + } + ] + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'attachment batch - JOAO' }] + } + ] + } + ] + }, + { + type: 'orderedList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'API module' }] + } + ] + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'UI module' }] + } + ] + } + ] + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Done' }] + } + ] + }, + project: { id: '10000', key: 'PM', name: 'Project Management' }, + issuetype: { id: '10001', name: 'Task' }, + status: { + id: '1', + name: 'To Do', + statusCategory: { key: 'new', name: 'To Do' } + }, + labels: [], + created: '2026-06-18T00:00:00.000Z', + updated: '2026-06-18T00:00:00.000Z' + } + }) + + expect(issue.description).toBe( + [ + 'História', + 'Coverage ownership', + '', + '- admin - JOAO', + '- attachment batch - JOAO', + '', + '1. API module', + '2. UI module', + '', + 'Done' + ].join('\n') + ) + }) + it('maps comments from the Jira comments page key', async () => { jiraRequestMock.mockResolvedValueOnce({ comments: [ diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 490e488cf59..60a7dc49f8e 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -29,6 +29,7 @@ import { release, type JiraClientForSite } from './client' +import { adfToMarkdownText, textToAdf } from './adf-markdown' const ISSUE_FIELDS = [ 'summary', @@ -264,67 +265,6 @@ function mapStatus(value: unknown): JiraStatus { } } -function textNode(text: string): JiraRecord { - return text ? { type: 'text', text } : { type: 'hardBreak' } -} - -function textToAdf(text: string): JiraRecord { - const lines = text.split(/\r?\n/) - return { - type: 'doc', - version: 1, - content: lines.map((line) => ({ - type: 'paragraph', - content: line ? [textNode(line)] : [] - })) - } -} - -function adfToPlainText(value: unknown): string { - const chunks: string[] = [] - - const walk = (node: unknown): void => { - if (!node) { - return - } - if (typeof node === 'string') { - chunks.push(node) - return - } - if (Array.isArray(node)) { - node.forEach(walk) - return - } - if (typeof node !== 'object') { - return - } - const record = node as JiraRecord - if (typeof record.text === 'string') { - chunks.push(record.text) - } - if (record.type === 'hardBreak') { - chunks.push('\n') - } - walk(record.content) - if ( - record.type === 'paragraph' || - record.type === 'heading' || - record.type === 'listItem' || - record.type === 'bulletList' || - record.type === 'orderedList' - ) { - chunks.push('\n') - } - } - - walk(value) - return chunks - .join('') - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim() -} - function issueUrl(site: JiraSite, key: string): string { return `${site.siteUrl}/browse/${encodeURIComponent(key)}` } @@ -338,7 +278,7 @@ export function mapJiraIssue(site: JiraSite, raw: JiraRecord): JiraIssue { siteId: site.id, siteName: site.displayName, title: asString(fields.summary, key || 'Untitled issue'), - description: adfToPlainText(fields.description), + description: adfToMarkdownText(fields.description), url: issueUrl(site, key), project: mapProject(fields.project, site), issueType: mapIssueType(fields.issuetype), @@ -592,7 +532,7 @@ export async function addIssueComment( function mapComment(raw: JiraRecord): JiraComment { return { id: asString(raw.id), - body: adfToPlainText(raw.body), + body: adfToMarkdownText(raw.body), createdAt: asString(raw.created, new Date().toISOString()), updatedAt: asString(raw.updated) || undefined, user: mapUser(raw.author) diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index 56ffbc802fb..3370e561363 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -659,6 +659,7 @@ export default function JiraIssueWorkspace({ {displayed.description?.trim() ? ( ) : ( From 8c4e99ed2f82275b6680c8c9750dbc3fdb5394ac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:37:15 -0700 Subject: [PATCH 007/163] Keep completed-agent hibernation pane-scoped (#5749) Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 74 +++ src/main/runtime/orca-runtime.ts | 18 +- src/main/runtime/rpc/methods/terminal.ts | 6 +- .../terminal-pane/pty-dispatcher.ts | 33 +- .../terminal-pane/pty-transport.test.ts | 21 + .../components/terminal-pane/pty-transport.ts | 1 + .../lib/agent-hibernation-coordinator.test.ts | 67 ++- .../src/lib/agent-hibernation-coordinator.ts | 33 +- .../src/lib/agent-hibernation-planner.test.ts | 49 +- .../src/lib/agent-hibernation-planner.ts | 73 +-- src/renderer/src/store/slices/agent-status.ts | 102 ++++ .../src/store/slices/store-cascades.test.ts | 477 ++++++++++++++++++ src/renderer/src/store/slices/terminals.ts | 214 ++++++++ 13 files changed, 1088 insertions(+), 80 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index c17547b2adc..77705cba693 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -12949,6 +12949,80 @@ describe('OrcaRuntimeService', () => { expect(stopped).toEqual([]) }) + it('allows target-only exact terminal stop when sibling PTYs remain live', async () => { + const runtime = new OrcaRuntimeService(store) + const stopped: string[] = [] + const processLists = [ + [ + { id: 'pty-1', cwd: TEST_WORKTREE_PATH, title: 'Claude' }, + { id: 'pty-shell', cwd: TEST_WORKTREE_PATH, title: 'Shell' } + ], + [{ id: 'pty-shell', cwd: TEST_WORKTREE_PATH, title: 'Shell' }] + ] + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: async (ptyId, opts) => { + stopped.push(ptyId) + expect(opts).toEqual({ keepHistory: true }) + runtime.onPtyExit(ptyId, -1) + return true + }, + getForegroundProcess: async () => null, + listProcesses: async () => processLists.shift() ?? [] + }) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + }, + { + tabId: 'tab-2', + worktreeId: TEST_WORKTREE_ID, + title: 'Shell', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + }, + { + tabId: 'tab-2', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 2, + ptyId: 'pty-shell' + } + ] + }) + + await expect( + runtime.stopExactTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`, ['pty-1'], { + keepHistory: true, + targetOnly: true + }) + ).resolves.toEqual({ + stopped: 1, + stoppedPtyIds: ['pty-1'], + livePtyIds: ['pty-1', 'pty-shell'], + postStopVerified: true, + remainingLivePtyIds: ['pty-shell'] + }) + expect(stopped).toEqual(['pty-1']) + }) + it('rejects exact terminal stop for multiple expected PTYs before stopping anything', async () => { const runtime = new OrcaRuntimeService(store) const stopped: string[] = [] diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aab4b825546..844f10d85b8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -13732,7 +13732,7 @@ export class OrcaRuntimeService { async stopExactTerminalsForWorktree( worktreeSelector: string, expectedPtyIds: readonly string[], - opts: { keepHistory?: boolean } = {} + opts: { keepHistory?: boolean; targetOnly?: boolean } = {} ): Promise<{ stopped: number stoppedPtyIds: string[] @@ -13741,8 +13741,8 @@ export class OrcaRuntimeService { postStopFailure?: string remainingLivePtyIds?: string[] }> { - // Why: hibernation may commit sleeping state only after the runtime proves - // the selected PTYs are still the complete live set for this worktree. + // Why: worktree sleep needs proof of the complete live set; pane hibernation + // only needs proof that its target PTY was live and is now gone. const graphEpoch = this.captureReadyGraphEpoch() const worktree = await this.resolveWorktreeSelector(worktreeSelector) this.assertStableReadyGraph(graphEpoch) @@ -13757,7 +13757,9 @@ export class OrcaRuntimeService { throw new Error('terminal_liveness_unavailable') } const livePtyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness) - if (!setsEqual(livePtyIds, expected)) { + const targetOnly = opts.targetOnly === true + const expectedIsLive = [...expected].every((ptyId) => livePtyIds.has(ptyId)) + if (targetOnly ? !expectedIsLive : !setsEqual(livePtyIds, expected)) { const error = Object.assign(new Error('terminal_stop_pty_set_mismatch'), { livePtyIds: [...livePtyIds].sort(), expectedPtyIds: [...expected].sort() @@ -13787,7 +13789,8 @@ export class OrcaRuntimeService { } } const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree.id, postStopLiveness) - if (remainingLivePtyIds.size > 0) { + const stoppedTargetsStillLive = [...expected].filter((ptyId) => remainingLivePtyIds.has(ptyId)) + if (targetOnly ? stoppedTargetsStillLive.length > 0 : remainingLivePtyIds.size > 0) { return { stopped: stoppedPtyIds.length, stoppedPtyIds, @@ -13801,7 +13804,10 @@ export class OrcaRuntimeService { stopped: stoppedPtyIds.length, stoppedPtyIds, livePtyIds: [...livePtyIds].sort(), - postStopVerified: true + postStopVerified: true, + ...(targetOnly && remainingLivePtyIds.size > 0 + ? { remainingLivePtyIds: [...remainingLivePtyIds].sort() } + : {}) } } diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 64fdd9b0e03..12301299176 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -539,7 +539,8 @@ const TerminalStop = z.object({ const TerminalStopExact = TerminalStop.extend({ expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), - keepHistory: z.boolean().optional() + keepHistory: z.boolean().optional(), + targetOnly: z.boolean().optional() }) const AgentTeamsTmuxCompat = z.object({ @@ -813,7 +814,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ params: TerminalStopExact, handler: async (params, { runtime }) => runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, { - keepHistory: params.keepHistory + keepHistory: params.keepHistory, + targetOnly: params.targetOnly }) }), defineMethod({ diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 916fe63c39c..3cd7a70b999 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -72,6 +72,13 @@ const ptyExitSidecars = new Map void>>() export const ptyTeardownHandlers = new Map void>() let ptyDispatcherAttached = false +export type PtyDataHandlerShutdownSnapshot = { + ptyId: string + dataHandler?: (data: string, meta?: PtyDataMeta) => void + replayHandler?: (data: string) => void + teardownHandler?: () => void +} + /** * Remove data and status handlers for the given PTY IDs so that any final * data flushed by the main process during PTY teardown cannot trigger @@ -82,13 +89,37 @@ let ptyDispatcherAttached = false * Exit handlers are intentionally kept alive so the normal exit-cleanup * path (unregister, clear stale timers, update store) still runs. */ -export function unregisterPtyDataHandlers(ptyIds: string[]): void { +export function unregisterPtyDataHandlers(ptyIds: string[]): PtyDataHandlerShutdownSnapshot[] { + const snapshots: PtyDataHandlerShutdownSnapshot[] = [] for (const id of ptyIds) { + snapshots.push({ + ptyId: id, + dataHandler: ptyDataHandlers.get(id), + replayHandler: ptyReplayHandlers.get(id), + teardownHandler: ptyTeardownHandlers.get(id) + }) ptyDataHandlers.delete(id) ptyReplayHandlers.delete(id) ptyTeardownHandlers.get(id)?.() ptyTeardownHandlers.delete(id) } + return snapshots +} + +export function restorePtyDataHandlersAfterFailedShutdown( + snapshots: readonly PtyDataHandlerShutdownSnapshot[] +): void { + for (const snapshot of snapshots) { + if (snapshot.dataHandler) { + ptyDataHandlers.set(snapshot.ptyId, snapshot.dataHandler) + } + if (snapshot.replayHandler) { + ptyReplayHandlers.set(snapshot.ptyId, snapshot.replayHandler) + } + if (snapshot.teardownHandler) { + ptyTeardownHandlers.set(snapshot.ptyId, snapshot.teardownHandler) + } + } } export function ensurePtyDispatcher(): void { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 1c4cd98a750..dc543fd0cb6 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -746,6 +746,27 @@ describe('createIpcPtyTransport', () => { expect(onPtyExit).toHaveBeenCalledWith('pty-1') }) + it('restores data handlers when an intentional shutdown fails before exit', async () => { + const { + createIpcPtyTransport, + restorePtyDataHandlersAfterFailedShutdown, + unregisterPtyDataHandlers + } = await import('./pty-transport') + const onDataCallback = vi.fn() + const transport = createIpcPtyTransport() + + await transport.connect({ url: '', callbacks: { onData: onDataCallback } }) + + const snapshots = unregisterPtyDataHandlers(['pty-1']) + onData?.({ id: 'pty-1', data: 'final burst while detached' }) + expect(onDataCallback).not.toHaveBeenCalled() + + restorePtyDataHandlersAfterFailedShutdown(snapshots) + onData?.({ id: 'pty-1', data: 'live again' }) + + expect(onDataCallback).toHaveBeenCalledWith('live again') + }) + it('unregisterPtyDataHandlers cancels staleTitleTimer so it cannot fire stale idle transition', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 00eb8dd54b3..1918ead1ac4 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -35,6 +35,7 @@ export { ensurePtyDispatcher, getEagerPtyBufferHandle, registerEagerPtyBuffer, + restorePtyDataHandlersAfterFailedShutdown, subscribeToPtyExit, unregisterPtyDataHandlers } from './pty-dispatcher' diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts index 5cfe68e8d56..3d5fa5df6d7 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts @@ -71,9 +71,9 @@ function entry(): AgentStatusEntry { } function installEligibleState( - shutdownWorktreeTerminals = vi.fn(), + shutdownCompletedAgentPaneForHibernation = vi.fn(), overrides: Partial = {} -): typeof shutdownWorktreeTerminals { +): typeof shutdownCompletedAgentPaneForHibernation { const e = entry() useAppStore.setState({ settings: { @@ -87,10 +87,11 @@ function installEligibleState( agentStatusByPaneKey: { [e.paneKey]: e }, sleepingAgentSessionsByPaneKey: {}, lastTerminalInputAtByPaneKey: {}, - shutdownWorktreeTerminals: shutdownWorktreeTerminals as never, + shutdownCompletedAgentPaneForHibernation: shutdownCompletedAgentPaneForHibernation as never, + shutdownWorktreeTerminals: vi.fn() as never, ...overrides }) - return shutdownWorktreeTerminals + return shutdownCompletedAgentPaneForHibernation } function runtimeListResult(ptyIds: string[], truncated = false) { @@ -179,9 +180,29 @@ describe('agent hibernation coordinator', () => { await vi.advanceTimersByTimeAsync(1000) expect(shutdown).toHaveBeenCalledWith('wt-bg', { - keepIdentifiers: true, - shutdownReason: 'auto-hibernate-completed-agent', - sleepingPaneKeys: [`tab-1:${LEAF}`] + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + leafId: LEAF, + ptyId: 'pty-1' + }) + expect(useAppStore.getState().shutdownWorktreeTerminals).not.toHaveBeenCalled() + }) + + it('hibernates an eligible pane when a sibling shell PTY is live', async () => { + vi.useFakeTimers() + const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), { + ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] } + }) + startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW }) + + await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(1000) + + expect(shutdown).toHaveBeenCalledWith('wt-bg', { + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + leafId: LEAF, + ptyId: 'pty-1' }) }) @@ -318,10 +339,11 @@ describe('agent hibernation coordinator', () => { await vi.advanceTimersByTimeAsync(1000) expect(shutdown).toHaveBeenCalledWith('wt-bg', { - keepIdentifiers: true, - shutdownReason: 'auto-hibernate-completed-agent', - sleepingPaneKeys: [`tab-1:${LEAF}`], - expectedRuntimePtyIds: ['pty-1'] + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + leafId: LEAF, + ptyId: 'pty-1', + expectedRuntimePtyId: 'pty-1' }) expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( expect.objectContaining({ @@ -336,7 +358,7 @@ describe('agent hibernation coordinator', () => { installRuntimeListResponses( runtimeListResult(['pty-1']), runtimeListResult(['pty-1']), - runtimeListResult(['pty-1', 'pty-shell']) + runtimeListResult(['pty-shell']) ) const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), { settings: { @@ -405,9 +427,12 @@ describe('agent hibernation coordinator', () => { expect(shutdown).not.toHaveBeenCalled() }) - it('skips runtime-backed candidates with multiple live PTYs', async () => { + it('hibernates runtime-backed candidates independently when siblings remain live', async () => { vi.useFakeTimers() installRuntimeListResponses( + runtimeListResult(['pty-1', 'pty-2']), + runtimeListResult(['pty-1', 'pty-2']), + runtimeListResult(['pty-1', 'pty-2']), runtimeListResult(['pty-1', 'pty-2']), runtimeListResult(['pty-1', 'pty-2']) ) @@ -440,7 +465,21 @@ describe('agent hibernation coordinator', () => { await vi.advanceTimersByTimeAsync(1000) await vi.advanceTimersByTimeAsync(1000) - expect(shutdown).not.toHaveBeenCalled() + expect(shutdown).toHaveBeenCalledTimes(2) + expect(shutdown).toHaveBeenCalledWith('wt-bg', { + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + leafId: LEAF, + ptyId: 'pty-1', + expectedRuntimePtyId: 'pty-1' + }) + expect(shutdown).toHaveBeenCalledWith('wt-bg', { + paneKey: `tab-1:${secondLeaf}`, + tabId: 'tab-1', + leafId: secondLeaf, + ptyId: 'pty-2', + expectedRuntimePtyId: 'pty-2' + }) }) it('fails closed on truncated runtime liveness samples', async () => { diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.ts b/src/renderer/src/lib/agent-hibernation-coordinator.ts index d8e39be85b4..b6d4c12a5b4 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -31,7 +31,7 @@ type AgentHibernationCoordinatorState = { interval: IntervalHandle | null confirmationState: AgentHibernationConfirmationState tickInFlight: boolean - shuttingDownWorktreeIds: Set + shuttingDownCandidateIds: Set now: () => number } @@ -39,7 +39,7 @@ const coordinator: AgentHibernationCoordinatorState = { interval: null, confirmationState: {}, tickInFlight: false, - shuttingDownWorktreeIds: new Set(), + shuttingDownCandidateIds: new Set(), now: () => Date.now() } @@ -152,37 +152,38 @@ async function currentCandidates(now: number) { })) } -async function hibernateWorktreeIfStillEligible( +async function hibernatePaneIfStillEligible( confirmedCandidate: AgentHibernationCandidate ): Promise { - const { worktreeId } = confirmedCandidate - if (coordinator.shuttingDownWorktreeIds.has(worktreeId)) { + const { id, worktreeId } = confirmedCandidate + if (coordinator.shuttingDownCandidateIds.has(id)) { return } const candidates = await currentCandidates(coordinator.now()) const stillEligible = candidates.some( (candidate) => - candidate.worktreeId === worktreeId && candidate.signature === confirmedCandidate.signature + candidate.id === confirmedCandidate.id && candidate.signature === confirmedCandidate.signature ) if (!stillEligible) { return } - coordinator.shuttingDownWorktreeIds.add(worktreeId) + coordinator.shuttingDownCandidateIds.add(id) try { const state = useAppStore.getState() const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) - await state.shutdownWorktreeTerminals(worktreeId, { - keepIdentifiers: true, - shutdownReason: 'auto-hibernate-completed-agent', - sleepingPaneKeys: confirmedCandidate.paneKeys, + await state.shutdownCompletedAgentPaneForHibernation(worktreeId, { + paneKey: confirmedCandidate.paneKey, + tabId: confirmedCandidate.tabId, + leafId: confirmedCandidate.leafId, + ptyId: confirmedCandidate.targetPtyIds[0], ...(runtimeEnvironmentId - ? { expectedRuntimePtyIds: confirmedCandidate.expectedRuntimePtyIds } + ? { expectedRuntimePtyId: confirmedCandidate.expectedRuntimePtyIds[0] } : {}) }) } catch (err) { - console.warn('[agent-hibernation] failed to hibernate worktree:', worktreeId, err) + console.warn('[agent-hibernation] failed to hibernate agent pane:', id, err) } finally { - coordinator.shuttingDownWorktreeIds.delete(worktreeId) + coordinator.shuttingDownCandidateIds.delete(id) } } @@ -198,7 +199,7 @@ export async function runAgentHibernationTick(): Promise { ) coordinator.confirmationState = plan.confirmationState for (const candidate of plan.candidates) { - void hibernateWorktreeIfStillEligible(candidate) + void hibernatePaneIfStillEligible(candidate) } } finally { coordinator.tickInFlight = false @@ -231,7 +232,7 @@ export function isAgentHibernationCoordinatorRunning(): boolean { export function resetAgentHibernationCoordinatorForTests(): void { stopAgentHibernationCoordinator() - coordinator.shuttingDownWorktreeIds.clear() + coordinator.shuttingDownCandidateIds.clear() coordinator.tickInFlight = false coordinator.now = () => Date.now() } diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index 11dd006323d..9732ef0dc27 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -82,6 +82,10 @@ function plannedWorktrees(input: AgentHibernationPlannerSnapshot): string[] { return planAgentHibernationCandidates(input).map((candidate) => candidate.worktreeId) } +function plannedPaneKeys(input: AgentHibernationPlannerSnapshot): string[] { + return planAgentHibernationCandidates(input).map((candidate) => candidate.paneKey) +} + describe('agent hibernation planner', () => { it('selects nothing when disabled, active, or foreground', () => { expect( @@ -128,10 +132,26 @@ describe('agent hibernation planner', () => { ).toEqual(['wt-bg']) }) - it('rejects untracked live PTYs and already-sleeping panes', () => { + it('emits a pane candidate when a sibling shell PTY is live', () => { expect( - plannedWorktrees(snapshot({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] } })) - ).toEqual([]) + planAgentHibernationCandidates( + snapshot({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] } }) + ) + ).toMatchObject([ + { + id: `wt-bg|tab-1:${LEAF}`, + worktreeId: 'wt-bg', + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + leafId: LEAF, + targetPtyIds: ['pty-1'], + expectedRuntimePtyIds: ['pty-1'], + paneKeys: [`tab-1:${LEAF}`] + } + ]) + }) + + it('rejects panes without live PTYs and already-sleeping panes', () => { expect(plannedWorktrees(snapshot({ ptyIdsByTabId: { 'tab-1': [] } }))).toEqual([]) expect( plannedWorktrees( @@ -144,6 +164,17 @@ describe('agent hibernation planner', () => { expect(plannedWorktrees(snapshot({ mobileLockedPtyIds: ['pty-1'] }))).toEqual([]) }) + it('does not let a mobile-locked sibling PTY block an unlocked target pane', () => { + expect( + plannedPaneKeys( + snapshot({ + ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] }, + mobileLockedPtyIds: ['pty-shell'] + }) + ) + ).toEqual([`tab-1:${LEAF}`]) + }) + it('selects runtime-backed live PTYs when the renderer live map is empty', () => { const [candidate] = planAgentHibernationCandidates( snapshot({ @@ -206,16 +237,16 @@ describe('agent hibernation planner', () => { ).toEqual([]) }) - it('rejects runtime-backed worktrees with extra unknown live PTYs', () => { + it('allows runtime-backed worktrees with sibling live PTYs', () => { expect( - plannedWorktrees( + plannedPaneKeys( snapshot({ ptyIdsByTabId: { 'tab-1': [] }, runtimeLivePtyIdsByWorktreeId: { 'wt-bg': ['pty-1', 'pty-shell'] }, runtimeLivenessRequiredWorktreeIds: ['wt-bg'] }) ) - ).toEqual([]) + ).toEqual([`tab-1:${LEAF}`]) }) it('applies mobile locks to runtime-backed PTYs', () => { @@ -245,14 +276,14 @@ describe('agent hibernation planner', () => { ).toEqual([]) }) - it('selects a worktree when all live PTYs are eligible done agents', () => { + it('selects each eligible done agent pane independently', () => { expect(plannedWorktrees(snapshot())).toEqual(['wt-bg']) const second = entry({ paneKey: `tab-1:${OTHER_LEAF}`, providerSession: { key: 'session_id', id: 'session-2' } }) expect( - plannedWorktrees( + plannedPaneKeys( snapshot({ terminalLayoutsByTabId: { 'tab-1': { @@ -271,7 +302,7 @@ describe('agent hibernation planner', () => { agentStatusByPaneKey: { [`tab-1:${LEAF}`]: entry(), [second.paneKey]: second } }) ) - ).toEqual(['wt-bg']) + ).toEqual([`tab-1:${LEAF}`, `tab-1:${OTHER_LEAF}`]) }) it('requires two stable ticks and resets on signature changes', () => { diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index 185798b6663..1f9560d10e2 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -29,8 +29,13 @@ export type AgentHibernationPlannerSnapshot = { } export type AgentHibernationCandidate = { + id: string worktreeId: string + paneKey: string + tabId: string + leafId: string paneKeys: string[] + targetPtyIds: string[] expectedRuntimePtyIds: string[] signature: string } @@ -44,6 +49,8 @@ export type AgentHibernationPlan = { type EligiblePane = { paneKey: string + tabId: string + leafId: string ptyId: string runtimePtyId: string providerSessionId: string @@ -90,12 +97,13 @@ function getLivePtyIdsForTab( function getPaneLivePtyId( entry: AgentStatusEntry, layout: TerminalLayoutSnapshot | undefined -): string | null { +): { leafId: string; ptyId: string } | null { const parsed = parsePaneKey(entry.paneKey) - if (!parsed || parsed.tabId !== entry.tabId) { + if (!parsed || (entry.tabId && parsed.tabId !== entry.tabId)) { return null } - return layout?.ptyIdsByLeafId?.[parsed.leafId] ?? null + const ptyId = layout?.ptyIdsByLeafId?.[parsed.leafId] + return ptyId ? { leafId: parsed.leafId, ptyId } : null } function getEntryTabId(entry: AgentStatusEntry): string | null { @@ -112,6 +120,7 @@ function getEligiblePane(args: { livePtyIds: Set sleepingAgentSessionsByPaneKey: AgentHibernationPlannerSnapshot['sleepingAgentSessionsByPaneKey'] lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey'] + mobileLockedPtyIds: Set now: number idleMs: number }): EligiblePane | null { @@ -121,7 +130,8 @@ function getEligiblePane(args: { layout, livePtyIds, sleepingAgentSessionsByPaneKey, - lastTerminalInputAtByPaneKey + lastTerminalInputAtByPaneKey, + mobileLockedPtyIds } = args if (entry.state !== 'done' || sleepingAgentSessionsByPaneKey[entry.paneKey]) { return null @@ -145,16 +155,19 @@ function getEligiblePane(args: { if (typeof inputAt === 'number' && Number.isFinite(inputAt) && inputAt > entry.updatedAt) { return null } - const ptyId = getPaneLivePtyId(entry, layout) - if (!ptyId) { + const livePane = getPaneLivePtyId(entry, layout) + if (!livePane) { return null } + const { leafId, ptyId } = livePane const runtimePtyId = toRuntimePtyId(ptyId) - if (!livePtyIds.has(runtimePtyId)) { + if (!livePtyIds.has(runtimePtyId) || mobileLockedPtyIds.has(runtimePtyId)) { return null } return { paneKey: entry.paneKey, + tabId: tab.id, + leafId, ptyId, runtimePtyId, providerSessionId: entry.providerSession.id, @@ -175,6 +188,10 @@ function signatureFor(worktreeId: string, panes: EligiblePane[]): string { return `${worktreeId}|${parts.join('|')}` } +function candidateIdFor(worktreeId: string, paneKey: string): string { + return `${worktreeId}|${paneKey}` +} + function getAgentEntriesByTabId( agentStatusByPaneKey: AgentHibernationPlannerSnapshot['agentStatusByPaneKey'] ): Map { @@ -229,9 +246,6 @@ export function planAgentHibernationCandidates( ) { continue } - const livePtyIds = new Set() - const eligibleByPtyId = new Map() - let rejected = false for (const tab of tabs) { const tabLivePtyIds = getLivePtyIdsForTab( tab, @@ -239,12 +253,6 @@ export function planAgentHibernationCandidates( snapshot.runtimeLivePtyIdsByWorktreeId, runtimeLivenessRequiredWorktreeIds.has(worktreeId) ) - for (const ptyId of tabLivePtyIds) { - livePtyIds.add(ptyId) - } - if (tabLivePtyIds.some((ptyId) => mobileLockedPtyIds.has(ptyId))) { - rejected = true - } if (tabLivePtyIds.length === 0) { continue } @@ -257,28 +265,29 @@ export function planAgentHibernationCandidates( livePtyIds: new Set(tabLivePtyIds), sleepingAgentSessionsByPaneKey: snapshot.sleepingAgentSessionsByPaneKey, lastTerminalInputAtByPaneKey: snapshot.lastTerminalInputAtByPaneKey, + mobileLockedPtyIds, now: snapshot.now, idleMs }) if (eligible) { - eligibleByPtyId.set(eligible.runtimePtyId, eligible) - } else if (entry.state !== 'done' || getPaneLivePtyId(entry, layout)) { - rejected = true + candidates.push({ + id: candidateIdFor(worktreeId, eligible.paneKey), + worktreeId, + paneKey: eligible.paneKey, + tabId: eligible.tabId, + leafId: eligible.leafId, + paneKeys: [eligible.paneKey], + targetPtyIds: [eligible.ptyId], + expectedRuntimePtyIds: [eligible.runtimePtyId], + signature: signatureFor(worktreeId, [eligible]) + }) } } } - if (rejected || livePtyIds.size === 0 || eligibleByPtyId.size !== livePtyIds.size) { - continue - } - const panes = [...eligibleByPtyId.values()] - candidates.push({ - worktreeId, - paneKeys: panes.map((pane) => pane.paneKey).sort(), - expectedRuntimePtyIds: [...livePtyIds].sort(), - signature: signatureFor(worktreeId, panes) - }) } - return candidates.sort((a, b) => a.worktreeId.localeCompare(b.worktreeId)) + return candidates.sort( + (a, b) => a.worktreeId.localeCompare(b.worktreeId) || a.paneKey.localeCompare(b.paneKey) + ) } export function confirmAgentHibernationCandidates( @@ -288,8 +297,8 @@ export function confirmAgentHibernationCandidates( const confirmationState: AgentHibernationConfirmationState = {} const confirmed: AgentHibernationCandidate[] = [] for (const candidate of candidates) { - confirmationState[candidate.worktreeId] = candidate.signature - if (previous[candidate.worktreeId] === candidate.signature) { + confirmationState[candidate.id] = candidate.signature + if (previous[candidate.id] === candidate.signature) { confirmed.push(candidate) } } diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 11b07cb205c..44d4e05594e 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -52,6 +52,10 @@ type DropAgentStatusByWorktreeOptions = { retainedCompletionEvidence?: readonly RetainedAgentEntry[] } +type DropHibernatedAgentPaneOptions = { + retainedCompletionEvidence?: readonly RetainedAgentEntry[] +} + export type AgentStatusSlice = { /** Explicit agent status entries keyed by `${tabId}:${leafId}` composite. * Real-time only — lives in renderer memory, not persisted to disk. */ @@ -114,6 +118,14 @@ export type AgentStatusSlice = { * remaining agent rows (live or retained) must not reappear. */ dropAgentStatusByTabPrefix: (tabIdPrefix: string) => void + /** Remove one automatically hibernated completed-agent pane while preserving + * sibling live/retained rows in the same worktree. */ + dropHibernatedAgentStatusPane: ( + worktreeId: string, + paneKey: string, + opts?: DropHibernatedAgentPaneOptions + ) => void + /** Remove all entries for a worktree AND suppress re-retention for live rows. * Used on worktree sleep/remove — the whole worktree surface is folding, so * retained rows must drop even if their original tab is no longer present. @@ -1111,6 +1123,96 @@ export const createAgentStatusSlice: StateCreator { + let hadLive = false + set((s) => { + const liveEntry = s.agentStatusByPaneKey[paneKey] + const hasLive = liveEntry !== undefined + const hasRetained = paneKey in s.retainedAgentsByPaneKey + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey === paneKey + ) + const retainedEvidence = new Map() + for (const retained of opts?.retainedCompletionEvidence ?? []) { + if ( + retained.entry.paneKey === paneKey && + !liveEntry && + shouldReplaceRetainedWithLive(retainedEvidence.get(paneKey), retained) + ) { + retainedEvidence.set(paneKey, retained) + } + } + if ( + liveEntry?.state === 'done' && + liveEntry.agentType !== undefined && + liveEntry.interrupted !== true + ) { + retainedEvidence.set( + paneKey, + retainedAgentEntryFromLive(s, worktreeId, liveEntry, liveEntry.agentType) + ) + } + const keepsCompletionEvidence = retainedEvidence.has(paneKey) + let nextAck = s.acknowledgedAgentsByPaneKey + if (!keepsCompletionEvidence && paneKey in nextAck) { + nextAck = { ...nextAck } + delete nextAck[paneKey] + } + if (!hasLive && !hasRetained && !migrationUnsupported.changed && !keepsCompletionEvidence) { + if (nextAck !== s.acknowledgedAgentsByPaneKey) { + return { acknowledgedAgentsByPaneKey: nextAck } + } + return s + } + hadLive = hasLive + + const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + if (hasLive) { + delete nextLive[paneKey] + } + + const nextRetained = + hasRetained || keepsCompletionEvidence + ? { ...s.retainedAgentsByPaneKey } + : s.retainedAgentsByPaneKey + if (hasRetained && !keepsCompletionEvidence) { + delete nextRetained[paneKey] + } + for (const [key, retained] of retainedEvidence) { + if (shouldReplaceRetainedWithLive(nextRetained[key], retained)) { + nextRetained[key] = retained + } + } + + const needsSuppressor = + hasLive && !keepsCompletionEvidence && !(paneKey in s.retentionSuppressedPaneKeys) + + return { + agentStatusByPaneKey: nextLive, + retainedAgentsByPaneKey: nextRetained, + migrationUnsupportedByPtyId: migrationUnsupported.next, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + ...(needsSuppressor + ? { + retentionSuppressedPaneKeys: { + ...s.retentionSuppressedPaneKeys, + [paneKey]: true + } + } + : {}), + agentStatusEpoch: + hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch + } + }) + if (hadLive) { + queueMicrotask(() => freshness.schedule()) + } + }, + dropAgentStatusByWorktree: (worktreeId, opts) => { let hadLive = false set((s) => { diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index fd7cc5ab5ed..2b793feaa75 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -8,6 +8,7 @@ import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rp import { toast } from 'sonner' const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn()) +const mockRestorePtyDataHandlersAfterFailedShutdown = vi.hoisted(() => vi.fn()) // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ @@ -15,6 +16,7 @@ vi.mock('sonner', () => ({ })) vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: mockRestorePtyDataHandlersAfterFailedShutdown, unregisterPtyDataHandlers: mockUnregisterPtyDataHandlers })) @@ -2627,6 +2629,481 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { shutdownBufferCaptures.clear() }) + it('automatically hibernates only the completed agent pane and preserves siblings', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const siblingLeaf = '22222222-2222-4222-8222-222222222222' + const targetPaneKey = `tab-1:${targetLeaf}` + const siblingPaneKey = `tab-1:${siblingLeaf}` + const dropByWorktree = vi.fn() + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex', ptyId: 'pty-agent' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: targetLeaf }, + second: { type: 'leaf', leafId: siblingLeaf } + }, + activeLeafId: siblingLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { [targetLeaf]: 'pty-agent', [siblingLeaf]: 'pty-shell' } + } + }, + ptyIdsByTabId: { 'tab-1': ['pty-agent', 'pty-shell'] }, + unreadTerminalTabs: { 'tab-1': true }, + unreadTerminalPanes: { [targetPaneKey]: true, [siblingPaneKey]: true }, + unreadAgentCompletionPanes: { [targetPaneKey]: true, [siblingPaneKey]: true }, + lastTerminalInputAtByPaneKey: { [targetPaneKey]: 1000, [siblingPaneKey]: 1100 }, + pendingSetupSplitByTabId: { 'tab-1': { command: 'setup', direction: 'horizontal' } }, + pendingIssueCommandSplitByTabId: { 'tab-1': { command: 'issue' } } + }) + store.setState({ dropAgentStatusByWorktree: dropByWorktree as never }) + store.getState().setAgentStatus( + targetPaneKey, + { + state: 'done', + prompt: 'resume target', + agentType: 'codex', + lastAssistantMessage: 'done' + }, + 'Codex', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + store + .getState() + .setAgentStatus( + siblingPaneKey, + { state: 'working', prompt: 'keep running', agentType: 'claude' }, + 'Claude', + { updatedAt: 2100, stateStartedAt: 2100 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'sibling-session' } } + ) + const siblingSleepingRecordBefore = + store.getState().sleepingAgentSessionsByPaneKey[siblingPaneKey] + + await store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'pty-agent' + }) + + const state = store.getState() + expect(mockApi.pty.kill).toHaveBeenCalledWith('pty-agent', { keepHistory: true }) + expect(mockApi.pty.kill).not.toHaveBeenCalledWith('pty-shell', expect.anything()) + expect(mockUnregisterPtyDataHandlers).toHaveBeenCalledWith(['pty-agent']) + expect(mockUnregisterPtyDataHandlers.mock.invocationCallOrder[0]).toBeLessThan( + mockApi.pty.kill.mock.invocationCallOrder[0] + ) + expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-shell']) + expect(state.tabsByWorktree[wt]?.[0]?.ptyId).toBe('pty-shell') + expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ + [targetLeaf]: 'pty-agent', + [siblingLeaf]: 'pty-shell' + }) + expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({ + providerSession: { key: 'session_id', id: 'target-session' } + }) + expect(state.sleepingAgentSessionsByPaneKey[siblingPaneKey]).toBe(siblingSleepingRecordBefore) + expect(state.agentStatusByPaneKey[targetPaneKey]).toBeUndefined() + expect(state.agentStatusByPaneKey[siblingPaneKey]).toBeDefined() + expect(state.retainedAgentsByPaneKey[targetPaneKey]).toMatchObject({ + entry: { lastAssistantMessage: 'done' } + }) + expect(state.unreadTerminalTabs['tab-1']).toBe(true) + expect(state.unreadTerminalPanes[targetPaneKey]).toBeUndefined() + expect(state.unreadTerminalPanes[siblingPaneKey]).toBe(true) + expect(state.unreadAgentCompletionPanes[targetPaneKey]).toBeUndefined() + expect(state.unreadAgentCompletionPanes[siblingPaneKey]).toBe(true) + expect(state.lastTerminalInputAtByPaneKey[targetPaneKey]).toBeUndefined() + expect(state.lastTerminalInputAtByPaneKey[siblingPaneKey]).toBe(1100) + expect(state.pendingSetupSplitByTabId['tab-1']).toBeDefined() + expect(state.pendingIssueCommandSplitByTabId['tab-1']).toBeDefined() + expect(dropByWorktree).not.toHaveBeenCalled() + }) + + it('keeps manual sleep worktree-wide', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Terminal', ptyId: 'pty-agent' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-agent', 'pty-shell'] } + }) + + await store.getState().shutdownWorktreeTerminals(wt, { keepIdentifiers: true }) + + expect(store.getState().ptyIdsByTabId['tab-1']).toEqual([]) + expect(mockApi.pty.kill).toHaveBeenCalledWith('pty-agent', { keepHistory: true }) + expect(mockApi.pty.kill).toHaveBeenCalledWith('pty-shell', { keepHistory: true }) + }) + + it('does not commit pane sleep state when local target kill fails', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const siblingLeaf = '22222222-2222-4222-8222-222222222222' + const targetPaneKey = `tab-1:${targetLeaf}` + const handlerSnapshots = [{ ptyId: 'pty-agent' }] + + mockApi.pty.kill.mockRejectedValueOnce(new Error('kill failed')) + mockUnregisterPtyDataHandlers.mockReturnValueOnce(handlerSnapshots) + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex', ptyId: 'pty-agent' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: targetLeaf }, + second: { type: 'leaf', leafId: siblingLeaf } + }, + activeLeafId: siblingLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { [targetLeaf]: 'pty-agent', [siblingLeaf]: 'pty-shell' } + } + }, + ptyIdsByTabId: { 'tab-1': ['pty-agent', 'pty-shell'] } + }) + store + .getState() + .setAgentStatus( + targetPaneKey, + { state: 'done', prompt: 'resume target', agentType: 'codex' }, + 'Codex', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + + await expect( + store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'pty-agent' + }) + ).rejects.toThrow('kill failed') + + const state = store.getState() + expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-agent', 'pty-shell']) + expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined() + expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined() + expect(state.suppressedPtyExitIds['pty-agent']).toBeUndefined() + expect(mockRestorePtyDataHandlersAfterFailedShutdown).toHaveBeenCalledWith(handlerSnapshots) + }) + + it('uses target-only runtime stop for automatic pane hibernation', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const siblingLeaf = '22222222-2222-4222-8222-222222222222' + const targetPaneKey = `tab-1:${targetLeaf}` + + mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => + Promise.resolve( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? { + id: 'rpc-default', + ok: true, + result: + args.method === 'terminal.stopExact' + ? { + stoppedPtyIds: ['terminal-1'], + livePtyIds: ['terminal-1', 'terminal-2'], + postStopVerified: true, + remainingLivePtyIds: ['terminal-2'] + } + : {}, + _meta: { runtimeId: 'remote-runtime' } + } + ) + ) + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: targetLeaf }, + second: { type: 'leaf', leafId: siblingLeaf } + }, + activeLeafId: siblingLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { + [targetLeaf]: 'remote:env-1@@terminal-1', + [siblingLeaf]: 'remote:env-1@@terminal-2' + } + } + }, + ptyIdsByTabId: { + 'tab-1': ['remote:env-1@@terminal-1', 'remote:env-1@@terminal-2'] + } + }) + store + .getState() + .setAgentStatus( + targetPaneKey, + { state: 'done', prompt: 'resume target', agentType: 'codex' }, + 'Codex', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + + await store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'remote:env-1@@terminal-1', + expectedRuntimePtyId: 'terminal-1' + }) + + expect(mockApi.runtimeEnvironments.call).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'runtime-1', + method: 'terminal.stopExact', + params: expect.objectContaining({ + expectedPtyIds: ['terminal-1'], + keepHistory: true, + targetOnly: true + }) + }) + ) + expect(mockApi.runtimeEnvironments.call).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.stop' }) + ) + expect(store.getState().ptyIdsByTabId['tab-1']).toEqual(['remote:env-1@@terminal-2']) + expect(mockApi.pty.kill).not.toHaveBeenCalled() + }) + + it('clears stale relay wake hints when pane hibernation leaves no live PTYs in the tab', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const targetPaneKey = `tab-1:${targetLeaf}` + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [ + makeTab({ + id: 'tab-1', + worktreeId: wt, + title: 'Codex', + ptyId: 'ssh:ssh-1@@pty-agent' + }) + ] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: targetLeaf }, + activeLeafId: targetLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { [targetLeaf]: 'ssh:ssh-1@@pty-agent' } + } + }, + ptyIdsByTabId: { 'tab-1': ['ssh:ssh-1@@pty-agent'] }, + lastKnownRelayPtyIdByTabId: { 'tab-1': 'ssh:ssh-1@@pty-agent' } + }) + store + .getState() + .setAgentStatus( + targetPaneKey, + { state: 'done', prompt: 'resume target', agentType: 'codex' }, + 'Codex', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + + await store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'ssh:ssh-1@@pty-agent' + }) + + expect(store.getState().ptyIdsByTabId['tab-1']).toEqual([]) + expect(store.getState().lastKnownRelayPtyIdByTabId['tab-1']).toBeUndefined() + }) + + it('does not retain stale completion evidence when pane status changes during hibernation', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const targetPaneKey = `tab-1:${targetLeaf}` + + mockApi.pty.kill.mockImplementationOnce(async () => { + store + .getState() + .setAgentStatus( + targetPaneKey, + { state: 'working', prompt: 'still running', agentType: 'codex' }, + 'Codex', + { updatedAt: 3000, stateStartedAt: 3000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + }) + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex', ptyId: 'pty-agent' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: targetLeaf }, + activeLeafId: targetLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { [targetLeaf]: 'pty-agent' } + } + }, + ptyIdsByTabId: { 'tab-1': ['pty-agent'] } + }) + store.getState().setAgentStatus( + targetPaneKey, + { + state: 'done', + prompt: 'stale done', + agentType: 'codex', + lastAssistantMessage: 'old done' + }, + 'Codex', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + + await store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'pty-agent' + }) + + expect(store.getState().agentStatusByPaneKey[targetPaneKey]).toBeUndefined() + expect(store.getState().retainedAgentsByPaneKey[targetPaneKey]).toBeUndefined() + expect(store.getState().retentionSuppressedPaneKeys[targetPaneKey]).toBe(true) + }) + + it('rolls back target suppressions when target-only runtime stop fails', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const siblingLeaf = '22222222-2222-4222-8222-222222222222' + const targetPaneKey = `tab-1:${targetLeaf}` + + mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => + Promise.resolve( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? { + id: 'rpc-default', + ok: true, + result: + args.method === 'terminal.stopExact' + ? { + stoppedPtyIds: ['terminal-1'], + livePtyIds: ['terminal-1', 'terminal-2'], + postStopVerified: false, + postStopFailure: 'target_still_live' + } + : {}, + _meta: { runtimeId: 'remote-runtime' } + } + ) + ) + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: targetLeaf }, + second: { type: 'leaf', leafId: siblingLeaf } + }, + activeLeafId: siblingLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { + [targetLeaf]: 'remote:env-1@@terminal-1', + [siblingLeaf]: 'remote:env-1@@terminal-2' + } + } + }, + ptyIdsByTabId: { + 'tab-1': ['remote:env-1@@terminal-1', 'remote:env-1@@terminal-2'] + } + }) + store + .getState() + .setAgentStatus( + targetPaneKey, + { state: 'done', prompt: 'resume target', agentType: 'codex' }, + 'Codex', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + + await expect( + store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'remote:env-1@@terminal-1', + expectedRuntimePtyId: 'terminal-1' + }) + ).rejects.toThrow('target_still_live') + + const state = store.getState() + expect(state.ptyIdsByTabId['tab-1']).toEqual([ + 'remote:env-1@@terminal-1', + 'remote:env-1@@terminal-2' + ]) + expect(state.suppressedPtyExitIds['remote:env-1@@terminal-1']).toBeUndefined() + expect(state.suppressedPtyExitIds['terminal-1']).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined() + expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined() + }) + it('records terminal input even before agent hibernation is enabled', () => { const store = createTestStore() diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index ff3a76dc209..4f527e93279 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -40,6 +40,7 @@ import { } from './tab-group-state' import { ensurePtyDispatcher, + restorePtyDataHandlersAfterFailedShutdown, unregisterPtyDataHandlers } from '@/components/terminal-pane/pty-transport' import { normalizeTerminalLayoutSnapshot } from '@/components/terminal-pane/terminal-layout-leaf-ids' @@ -435,6 +436,16 @@ export type TerminalSlice = { expectedRuntimePtyIds?: string[] } ) => Promise + shutdownCompletedAgentPaneForHibernation: ( + worktreeId: string, + opts: { + paneKey: string + tabId: string + leafId: string + ptyId: string + expectedRuntimePtyId?: string + } + ) => Promise suppressPtyExit: (ptyId: string) => void consumeSuppressedPtyExit: (ptyId: string) => boolean queueCodexPaneRestarts: (ptyIds: string[]) => void @@ -1685,6 +1696,209 @@ export const createTerminalSlice: StateCreator } }, + shutdownCompletedAgentPaneForHibernation: async (worktreeId, opts) => { + const paneKeys = [opts.paneKey] + const expectedRuntimePtyIds = sortedUniquePtyIds( + opts.expectedRuntimePtyId ? [opts.expectedRuntimePtyId] : [] + ) + const shutdownPtyIds = sortedUniquePtyIds([opts.ptyId, ...expectedRuntimePtyIds]) + const state = get() + const tab = (state.tabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === opts.tabId + ) + const parsed = parsePaneKey(opts.paneKey) + const layout = state.terminalLayoutsByTabId[opts.tabId] + const liveTabPtyIds = state.ptyIdsByTabId[opts.tabId] ?? [] + if ( + !tab || + !parsed || + parsed.tabId !== opts.tabId || + parsed.leafId !== opts.leafId || + layout?.ptyIdsByLeafId?.[opts.leafId] !== opts.ptyId || + (expectedRuntimePtyIds.length === 0 && !liveTabPtyIds.includes(opts.ptyId)) + ) { + throw new Error('agent_hibernation_pane_binding_mismatch') + } + + const sleepingAgentSessionRecords = collectSleepingAgentSessionRecordsForWorktree( + state, + worktreeId, + paneKeys + ) + const retainedCompletionEvidence = collectHibernatedCompletionEvidenceForWorktree( + state, + worktreeId, + paneKeys + ) + + const capture = shutdownBufferCaptures.get(opts.tabId) + if (capture) { + try { + capture({ includeLocalBuffers: false }) + } catch { + // Don't let one tab's capture failure block the pane hibernation. + } + } + + const clearTargetSuppressions = (): void => { + set((s) => { + const next = { ...s.suppressedPtyExitIds } + for (const ptyId of shutdownPtyIds) { + delete next[ptyId] + } + return { suppressedPtyExitIds: next } + }) + } + + set((s) => ({ + suppressedPtyExitIds: { + ...s.suppressedPtyExitIds, + ...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const)) + } + })) + + if (expectedRuntimePtyIds.length > 0) { + const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId) + if (!runtimeEnvironmentId) { + clearTargetSuppressions() + throw new Error('missing_runtime_for_exact_terminal_stop') + } + let stopResult: { + stoppedPtyIds?: string[] + livePtyIds?: string[] + postStopVerified?: boolean + postStopFailure?: string + } + try { + stopResult = await callRuntimeRpc<{ + stoppedPtyIds?: string[] + livePtyIds?: string[] + postStopVerified?: boolean + postStopFailure?: string + }>( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'terminal.stopExact', + { + worktree: toRuntimeWorktreeSelector(worktreeId), + expectedPtyIds: expectedRuntimePtyIds, + keepHistory: true, + targetOnly: true + }, + { timeoutMs: 15_000 } + ) + } catch (err) { + clearTargetSuppressions() + throw err + } + const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds) + const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds) + const targetWasLive = expectedRuntimePtyIds.every((ptyId) => livePtyIds.includes(ptyId)) + if (!equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || !targetWasLive) { + clearTargetSuppressions() + throw new Error('exact_terminal_stop_mismatch') + } + if (stopResult.postStopVerified !== true) { + clearTargetSuppressions() + throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified') + } + unregisterPtyDataHandlers(shutdownPtyIds) + } else if (!opts.ptyId.startsWith('remote:')) { + // Why: pty.kill can flush final data before exit; unregister first so + // pane hibernation cannot fire phantom notifications from stale handlers. + const handlerSnapshots = unregisterPtyDataHandlers(shutdownPtyIds) + try { + await window.api.pty.kill(opts.ptyId, { keepHistory: true }) + } catch (err) { + restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots) + clearTargetSuppressions() + throw err + } + } + + set((s) => { + const existingPtyIds = s.ptyIdsByTabId[opts.tabId] ?? [] + const shutdownPtyIdSet = new Set(shutdownPtyIds) + const remainingPtyIds = existingPtyIds.filter((ptyId) => !shutdownPtyIdSet.has(ptyId)) + const nextTabsByWorktree = { ...s.tabsByWorktree } + const tabs = nextTabsByWorktree[worktreeId] ?? [] + const tabIndex = tabs.findIndex((candidate) => candidate.id === opts.tabId) + if (tabIndex !== -1) { + const nextTabs = [...tabs] + nextTabs[tabIndex] = { + ...nextTabs[tabIndex], + ptyId: remainingPtyIds.at(-1) ?? null + } + nextTabsByWorktree[worktreeId] = nextTabs + } + + const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } + for (const ptyId of shutdownPtyIds) { + delete nextCodexRestartNoticeByPtyId[ptyId] + } + const nextLastKnownRelay = + remainingPtyIds.length === 0 + ? { ...s.lastKnownRelayPtyIdByTabId } + : s.lastKnownRelayPtyIdByTabId + if (remainingPtyIds.length === 0) { + delete nextLastKnownRelay[opts.tabId] + } + + let nextRuntimePaneTitlesByTabId = s.runtimePaneTitlesByTabId + const numericPaneId = Number(opts.leafId) + if ( + Number.isInteger(numericPaneId) && + s.runtimePaneTitlesByTabId[opts.tabId]?.[numericPaneId] + ) { + const nextByPane = { ...s.runtimePaneTitlesByTabId[opts.tabId] } + delete nextByPane[numericPaneId] + nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } + if (Object.keys(nextByPane).length > 0) { + nextRuntimePaneTitlesByTabId[opts.tabId] = nextByPane + } else { + delete nextRuntimePaneTitlesByTabId[opts.tabId] + } + } + + const nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } + const nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } + const nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey } + delete nextUnreadTerminalPanes[opts.paneKey] + delete nextUnreadAgentCompletionPanes[opts.paneKey] + delete nextLastTerminalInputAtByPaneKey[opts.paneKey] + + return { + tabsByWorktree: nextTabsByWorktree, + ptyIdsByTabId: { + ...s.ptyIdsByTabId, + [opts.tabId]: remainingPtyIds + }, + lastKnownRelayPtyIdByTabId: nextLastKnownRelay, + suppressedPtyExitIds: { + ...s.suppressedPtyExitIds, + ...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const)) + }, + codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, + ...(nextRuntimePaneTitlesByTabId !== s.runtimePaneTitlesByTabId + ? { runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId } + : {}), + unreadTerminalPanes: nextUnreadTerminalPanes, + unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes, + lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey + } + }) + + set((s) => ({ + sleepingAgentSessionsByPaneKey: { + ...s.sleepingAgentSessionsByPaneKey, + ...sleepingAgentSessionRecords + } + })) + + get().dropHibernatedAgentStatusPane(worktreeId, opts.paneKey, { + retainedCompletionEvidence + }) + }, + shutdownWorktreeTerminals: async (worktreeId, opts) => { const keepIdentifiers = opts?.keepIdentifiers ?? false const shutdownReason: AgentStatusWorktreeShutdownReason = From f19390a8a238191e512924c42e13e16f2a907a9e Mon Sep 17 00:00:00 2001 From: Wolfie Date: Thu, 18 Jun 2026 22:05:08 -0700 Subject: [PATCH 008/163] fix(mobile): clarify legacy voice settings runtime error (#5751) * fix(mobile): clarify legacy voice settings runtime error * test(mobile): cover actual legacy speech method miss Co-authored-by: Orca --------- Co-authored-by: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../dictation/mobile-dictation-setup.test.ts | 54 +++++++++++++++++++ .../src/dictation/mobile-dictation-setup.ts | 17 ++++++ 2 files changed, 71 insertions(+) diff --git a/mobile/src/dictation/mobile-dictation-setup.test.ts b/mobile/src/dictation/mobile-dictation-setup.test.ts index 7fa1ca22137..375f989f111 100644 --- a/mobile/src/dictation/mobile-dictation-setup.test.ts +++ b/mobile/src/dictation/mobile-dictation-setup.test.ts @@ -18,6 +18,10 @@ function ok(result: unknown): RpcSuccess { function fail(message: string): RpcFailure { return { id: 'r', ok: false, error: { code: 'x', message }, _meta: { runtimeId: 'rt' } } } +function malformedFailure(error: { code?: string; message?: string }): RpcResponse { + return { id: 'r', ok: false, error, _meta: { runtimeId: 'rt' } } as unknown as RpcResponse +} + function clientWith(responses: RpcResponse[]): Pick & { calls: Array<{ method: string; params: unknown }> } { @@ -84,6 +88,56 @@ describe('rpc wrappers', () => { const client = clientWith([fail('disconnected')]) await expect(fetchDictationSetup(client)).rejects.toThrow('disconnected') }) + + it('maps legacy desktop denials to update guidance', async () => { + const client = clientWith([ + { + id: 'r', + ok: false, + error: { + code: 'forbidden', + message: "Method 'speech.models.list' is not available to mobile clients" + }, + _meta: { runtimeId: 'rt' } + } + ]) + + await expect(fetchDictationSetup(client)).rejects.toThrow( + 'Update the paired desktop Orca app to use mobile voice settings.' + ) + }) + + it('maps legacy desktop method-not-found failures to update guidance', async () => { + const client = clientWith([ + malformedFailure({ + code: 'method_not_found', + message: 'Unknown method: speech.models.list' + }) + ]) + + await expect(fetchDictationSetup(client)).rejects.toThrow( + 'Update the paired desktop Orca app to use mobile voice settings.' + ) + }) + + it('keeps unrelated speech model failures specific', async () => { + const client = clientWith([ + malformedFailure({ + code: 'internal_error', + message: 'speech.models.list failed unexpectedly' + }) + ]) + + await expect(fetchDictationSetup(client)).rejects.toThrow( + 'speech.models.list failed unexpectedly' + ) + }) + + it('falls back when runtime failures omit a message', async () => { + const client = clientWith([malformedFailure({ code: 'internal_error' })]) + + await expect(fetchDictationSetup(client)).rejects.toThrow('Failed to load dictation models') + }) }) describe('state helpers', () => { diff --git a/mobile/src/dictation/mobile-dictation-setup.ts b/mobile/src/dictation/mobile-dictation-setup.ts index 9c822922b02..48946571094 100644 --- a/mobile/src/dictation/mobile-dictation-setup.ts +++ b/mobile/src/dictation/mobile-dictation-setup.ts @@ -9,6 +9,20 @@ export type MobileSpeechModel = RuntimeSpeechSetupState['models'][number] // configured. Mapping them lets the mic entry point open the setup sheet // instead of dead-ending on a toast. const SETUP_REQUIRED_CODES = new Set(['voice_dictation_disabled', 'voice_model_not_selected']) +const LEGACY_DESKTOP_SPEECH_SETUP_MESSAGE = + 'Update the paired desktop Orca app to use mobile voice settings.' + +// Why: mobile can pair with older desktop runtimes that predate speech.models.list; +// show upgrade guidance instead of leaking the raw denial or not-found error. +function isLegacyDesktopSpeechSetupError( + error: { code?: string; message?: string } | undefined +): boolean { + const message = error?.message ?? '' + return ( + message.includes('speech.models.list') && + (error?.code === 'method_not_found' || message.includes('not available to mobile clients')) + ) +} export function isDictationSetupRequiredError(message: string): boolean { return SETUP_REQUIRED_CODES.has(message) || message.startsWith('voice_model_not_ready:') @@ -19,6 +33,9 @@ export async function fetchDictationSetup( ): Promise { const response = await client.sendRequest('speech.models.list', null) if (!response.ok) { + if (isLegacyDesktopSpeechSetupError(response.error)) { + throw new Error(LEGACY_DESKTOP_SPEECH_SETUP_MESSAGE) + } throw new Error(response.error?.message || 'Failed to load dictation models') } return (response as RpcSuccess).result as MobileSpeechSetup From f80704cc6023a7e0cd522f66c1ee2bc3dc7d7474 Mon Sep 17 00:00:00 2001 From: slashdevcorpse <73707049+slashdevcorpse@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:21:48 -0400 Subject: [PATCH 009/163] [perf] Speed up CLI launch through Orca Speed up Windows CLI launch by delivering short startup commands through shell args, falling back to stdin for oversized commands, and moving Codex historical session bridging into an incremental background task. Includes review fixes for Windows symlink and WSL test reliability. --- config/vitest.config.ts | 11 +- src/main/claude/hook-service.test.ts | 2 +- .../runtime-home-service.test.ts | 5 +- .../codex-accounts/runtime-home-service.ts | 12 +- src/main/codex/codex-session-bridge.test.ts | 94 +++++++++- src/main/codex/codex-session-bridge.ts | 170 ++++++++++++++---- src/main/codex/codex-session-file-listing.ts | 99 ++++++++++ src/main/codex/hook-service.test.ts | 2 +- src/main/daemon/pty-subprocess.test.ts | 59 ++++++ src/main/daemon/pty-subprocess.ts | 68 ++++++- src/main/daemon/session.ts | 3 + src/main/daemon/terminal-host.test.ts | 32 +++- src/main/daemon/terminal-host.ts | 8 +- src/main/devin/hook-service.test.ts | 15 +- .../ipc/pty-startup-barrier-ordering.test.ts | 5 +- src/main/ipc/pty.test.ts | 6 +- src/main/providers/local-pty-provider.ts | 54 +++++- src/main/providers/windows-shell-args.test.ts | 80 ++++++++- src/main/providers/windows-shell-args.ts | 80 ++++++++- src/relay/git-handler-branch-cleanup.test.ts | 3 +- .../git-handler-pull-reconciliation.test.ts | 4 +- src/relay/git-handler-worktree-ops.test.ts | 3 +- src/relay/git-handler-worktree-paths.test.ts | 3 +- ...rkspaceNameField-source-boundaries.test.ts | 5 +- .../WorktreeCard.compact-hover.test.tsx | 8 +- .../WorktreeCardAgents.send-target.test.tsx | 2 +- .../sidebar/WorktreeCardAgents.test.tsx | 2 +- ...ktreeList.lineage-child-real-card.test.tsx | 2 +- .../components/tab-bar/BrowserTab.test.tsx | 2 +- .../TabBar.windows-shell-launch.test.ts | 2 +- .../terminal-pane/pty-connection.test.ts | 2 +- src/shared/wsl-login-shell-command.test.ts | 51 ++++-- 32 files changed, 792 insertions(+), 102 deletions(-) create mode 100644 src/main/codex/codex-session-file-listing.ts diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 5ef1490bfb9..b3846c92cc2 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -1,6 +1,8 @@ import { resolve } from 'path' import { defineConfig } from 'vitest/config' +const windowsTestWorkerOptions = process.platform === 'win32' ? { maxWorkers: 4 } : {} + export default defineConfig({ define: { ORCA_FEATURE_WALL_ENABLED: 'true' @@ -13,6 +15,13 @@ export default defineConfig({ }, test: { environment: 'node', - include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'config/scripts/**/*.test.mjs'] + include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'config/scripts/**/*.test.mjs'], + // Why: the full suite runs heavy TS transforms plus real git/http fixtures; + // the Vitest 5s defaults are too tight for the slowest integration cases. + hookTimeout: 60_000, + testTimeout: 30_000, + // Why: Windows process and shell startup are slower under full-suite load; + // macOS/Linux keep Vitest's default worker parallelism. + ...windowsTestWorkerOptions } }) diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index e0ad45b63e2..3b280183bfe 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -293,7 +293,7 @@ describe('OpenClaudeHookService-compatible install', () => { readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).toContain('/hook/claude') expect( - readFileSync(join(tmpHome, '.orca', 'agent-hooks', 'openclaude-hook.sh'), 'utf-8') + readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).not.toContain('DEVIN_PROJECT_DIR') expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false) } finally { diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 92b6f898738..dd29c2ebcc3 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1095,7 +1095,7 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeProfilePath, 'utf-8')).toBe('profile\n') }) - it('bridges system Codex sessions before launch without replacing runtime sessions', async () => { + it('starts the system Codex session bridge without replacing runtime sessions', async () => { const systemMissingRuntimeSessionPath = join( getSystemCodexHomePath(), 'sessions', @@ -1130,9 +1130,12 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(join(getSystemCodexHomePath(), 'state_5.sqlite'), 'sqlite\n', 'utf-8') const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const { startSystemCodexSessionBridgeInBackground } = + await import('../codex/codex-session-bridge') const service = new CodexRuntimeHomeService(store as never) service.prepareForCodexLaunch() + await startSystemCodexSessionBridgeInBackground() const runtimeMissingSessionPath = join( getRuntimeCodexHomePath(), diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 7024b8fa95a..12d119a8510 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -39,7 +39,7 @@ import { getSystemCodexHomePath, syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' -import { syncSystemCodexSessionsIntoManagedHome } from '../codex/codex-session-bridge' +import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' import { parseWslUncPath } from '../../shared/wsl-paths' import { @@ -122,6 +122,12 @@ export class CodexRuntimeHomeService { : normalizeCodexRuntimeSelection(settings).host } + /** + * Materializes the runtime home needed before launching the CLI. + * + * Historical session bridging is requested in the background so launch setup + * returns as soon as the active runtime home is ready. + */ prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) @@ -133,7 +139,9 @@ export class CodexRuntimeHomeService { this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() - syncSystemCodexSessionsIntoManagedHome() + // Why: historical Codex sessions can be large; bridge them after launch + // setup so starting a fresh Codex TUI never waits on a full tree walk. + void startSystemCodexSessionBridgeInBackground() return this.getRuntimeHomePath() } diff --git a/src/main/codex/codex-session-bridge.test.ts b/src/main/codex/codex-session-bridge.test.ts index 3ee5ebec22e..ae4e17be693 100644 --- a/src/main/codex/codex-session-bridge.test.ts +++ b/src/main/codex/codex-session-bridge.test.ts @@ -20,9 +20,21 @@ const { homedirMock } = vi.hoisted(() => ({ })) const { fsMockState } = vi.hoisted(() => ({ - fsMockState: { failLink: false, failSymlink: false } + fsMockState: { + failLink: false, + failSymlink: false, + fakeSymlinks: new Map() + } })) +function isWindowsSymlinkPrivilegeError(error: unknown): boolean { + if (process.platform !== 'win32' || !(error instanceof Error)) { + return false + } + const errorWithCode = error as Error & { code?: string } + return errorWithCode.code === 'EPERM' || errorWithCode.code === 'EACCES' +} + vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { @@ -33,11 +45,52 @@ vi.mock('node:fs', async () => { } return actual.linkSync(...args) }, + lstatSync: ((path: Parameters[0]) => { + const stat = actual.lstatSync(path) + if (!fsMockState.fakeSymlinks.has(String(path))) { + return stat + } + // Why: Windows often disallows file symlink creation outside Developer + // Mode; tests simulate the link metadata while keeping a real path. + return { ...stat, isSymbolicLink: () => true } + }) as typeof actual.lstatSync, + readlinkSync: ((path: Parameters[0]) => { + const fakeTarget = fsMockState.fakeSymlinks.get(String(path)) + if (fakeTarget !== undefined) { + return fakeTarget + } + return actual.readlinkSync(path) + }) as typeof actual.readlinkSync, + renameSync: (...args: Parameters) => { + const [oldPath, newPath] = args + const fakeTarget = fsMockState.fakeSymlinks.get(String(oldPath)) + const result = actual.renameSync(...args) + if (fakeTarget !== undefined) { + fsMockState.fakeSymlinks.delete(String(oldPath)) + fsMockState.fakeSymlinks.set(String(newPath), fakeTarget) + } else { + fsMockState.fakeSymlinks.delete(String(newPath)) + } + return result + }, + rmSync: (...args: Parameters) => { + fsMockState.fakeSymlinks.delete(String(args[0])) + return actual.rmSync(...args) + }, symlinkSync: (...args: Parameters) => { if (fsMockState.failSymlink) { throw new Error('symlink disabled for test') } - return actual.symlinkSync(...args) + try { + return actual.symlinkSync(...args) + } catch (error) { + if (!isWindowsSymlinkPrivilegeError(error)) { + throw error + } + const [target, path] = args + fsMockState.fakeSymlinks.set(String(path), String(target)) + actual.writeFileSync(path, '', 'utf-8') + } } } }) @@ -50,7 +103,10 @@ vi.mock('node:os', async () => { } }) -import { syncSystemCodexSessionsIntoManagedHome } from './codex-session-bridge' +import { + syncSystemCodexSessionsIntoManagedHome, + syncSystemCodexSessionsIntoManagedHomeIncrementally +} from './codex-session-bridge' let fakeHomeDir: string let userDataDir: string @@ -103,6 +159,7 @@ function writeLegacyCopyMarker(relativePath: string, sourcePath: string, targetP beforeEach(() => { fsMockState.failLink = false fsMockState.failSymlink = false + fsMockState.fakeSymlinks.clear() fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-session-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-session-user-data-')) previousUserDataPath = process.env.ORCA_USER_DATA_PATH @@ -276,4 +333,35 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => { expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(false) expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"legacy"}\n') }) + + it('incrementally bridges session files without requiring the synchronous launch path', async () => { + const systemSessionRoot = join(getSystemCodexHomePath(), 'sessions', '2026', '06', '18') + mkdirSync(systemSessionRoot, { recursive: true }) + for (let index = 0; index < 5; index += 1) { + writeFileSync( + join(systemSessionRoot, `rollout-incremental-${index}.jsonl`), + `{"id":"incremental-${index}"}\n`, + 'utf-8' + ) + } + + const summary = await syncSystemCodexSessionsIntoManagedHomeIncrementally({ + batchSize: 2, + yieldMs: 0 + }) + + expect(summary).toEqual({ scannedFiles: 5, linkedFiles: 5 }) + for (let index = 0; index < 5; index += 1) { + const systemSessionPath = join(systemSessionRoot, `rollout-incremental-${index}.jsonl`) + const runtimeSessionPath = join( + getRuntimeCodexHomePath(), + 'sessions', + '2026', + '06', + '18', + `rollout-incremental-${index}.jsonl` + ) + expectResourceLinked(runtimeSessionPath, systemSessionPath) + } + }) }) diff --git a/src/main/codex/codex-session-bridge.ts b/src/main/codex/codex-session-bridge.ts index 7a2815f029d..a574228953e 100644 --- a/src/main/codex/codex-session-bridge.ts +++ b/src/main/codex/codex-session-bridge.ts @@ -3,7 +3,6 @@ import { linkSync, lstatSync, mkdirSync, - readdirSync, readFileSync, readlinkSync, renameSync, @@ -12,6 +11,13 @@ import { } from 'node:fs' import { dirname, isAbsolute, join, relative, sep } from 'node:path' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { + listCodexSessionJsonlFiles, + listCodexSessionJsonlFilesIncrementally +} from './codex-session-file-listing' +import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' + +export type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' type LegacyCopiedSessionMarker = { sourcePath: string @@ -27,6 +33,16 @@ export type LegacyCopiedCodexSessionBridgeScanPreference = { sourceSkipBytes: number | null } +export type CodexSessionBridgeSummary = { + scannedFiles: number + linkedFiles: number +} + +let backgroundSessionBridgeTask: Promise | null = null + +/** + * Synchronously mirrors system session files into the managed runtime home. + */ export function syncSystemCodexSessionsIntoManagedHome(): void { const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions') if (!existsSync(systemSessionsRoot)) { @@ -35,53 +51,99 @@ export function syncSystemCodexSessionsIntoManagedHome(): void { const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions') for (const systemSessionFilePath of listCodexSessionJsonlFiles(systemSessionsRoot)) { - const relativePath = relative(systemSessionsRoot, systemSessionFilePath) - const managedSessionFilePath = join(managedSessionsRoot, relativePath) - if (existsSync(managedSessionFilePath)) { - if ( - replaceSymlinkSessionBridgeWithHardlink( - systemSessionFilePath, - managedSessionFilePath, - relativePath - ) - ) { - continue - } - migrateLegacyCopiedSessionBridge(systemSessionFilePath, managedSessionFilePath, relativePath) - continue + bridgeSystemCodexSessionFile(systemSessionsRoot, managedSessionsRoot, systemSessionFilePath) + } +} + +/** + * Starts a single background bridge task for historical system sessions. + * + * Concurrent callers share the same in-flight task so launch code can request + * background bridging without starting duplicate directory walks. + */ +export function startSystemCodexSessionBridgeInBackground( + options: CodexSessionBridgeIncrementalOptions = {} +): Promise { + if (backgroundSessionBridgeTask) { + return backgroundSessionBridgeTask + } + const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options) + .catch((error: unknown) => { + console.warn('[codex-session-bridge] Background session bridge failed:', error) + }) + .then(() => undefined) + backgroundSessionBridgeTask = task + void task.finally(() => { + if (backgroundSessionBridgeTask === task) { + backgroundSessionBridgeTask = null } - mkdirSync(dirname(managedSessionFilePath), { recursive: true }) - linkSystemCodexSessionFile(systemSessionFilePath, managedSessionFilePath, relativePath) - } + }) + return task } -function listCodexSessionJsonlFiles(rootPath: string): string[] { - const files: string[] = [] - try { - for (const entry of readdirSync(rootPath, { withFileTypes: true })) { - const childPath = join(rootPath, entry.name) - if (entry.isDirectory()) { - appendSessionFilePaths(files, listCodexSessionJsonlFiles(childPath)) - continue - } - if (entry.isFile() && entry.name.endsWith('.jsonl')) { - files.push(childPath) - } +/** + * Incrementally mirrors system session files into the managed runtime home. + * + * Returns scan/link counts for tests and diagnostics while keeping each file + * bridge operation equivalent to the synchronous path. + */ +export async function syncSystemCodexSessionsIntoManagedHomeIncrementally( + options: CodexSessionBridgeIncrementalOptions = {} +): Promise { + const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions') + if (!existsSync(systemSessionsRoot)) { + return { scannedFiles: 0, linkedFiles: 0 } + } + + const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions') + const summary: CodexSessionBridgeSummary = { scannedFiles: 0, linkedFiles: 0 } + for await (const systemSessionFilePath of listCodexSessionJsonlFilesIncrementally( + systemSessionsRoot, + options + )) { + summary.scannedFiles += 1 + if ( + bridgeSystemCodexSessionFile(systemSessionsRoot, managedSessionsRoot, systemSessionFilePath) + ) { + summary.linkedFiles += 1 } - } catch (error) { - console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) } - return files.sort() + return summary } -function appendSessionFilePaths(target: string[], source: readonly string[]): void { - // Why: existing Codex homes can accumulate enough nested sessions to exceed - // V8's argument limit if child arrays are spread into push(). - for (const filePath of source) { - target.push(filePath) +/** + * Bridges one system session file into the managed sessions tree. + * + * Existing managed files are migrated when possible; missing files are linked + * and counted as newly available to the managed runtime home. + */ +function bridgeSystemCodexSessionFile( + systemSessionsRoot: string, + managedSessionsRoot: string, + systemSessionFilePath: string +): boolean { + const relativePath = relative(systemSessionsRoot, systemSessionFilePath) + const managedSessionFilePath = join(managedSessionsRoot, relativePath) + if (existsSync(managedSessionFilePath)) { + if ( + replaceSymlinkSessionBridgeWithHardlink( + systemSessionFilePath, + managedSessionFilePath, + relativePath + ) + ) { + return true + } + migrateLegacyCopiedSessionBridge(systemSessionFilePath, managedSessionFilePath, relativePath) + return false } + mkdirSync(dirname(managedSessionFilePath), { recursive: true }) + return linkSystemCodexSessionFile(systemSessionFilePath, managedSessionFilePath, relativePath) } +/** + * Links a source session file and clears any stale copied-session marker. + */ function linkSystemCodexSessionFile( sourcePath: string, targetPath: string, @@ -94,6 +156,9 @@ function linkSystemCodexSessionFile( return linked } +/** + * Attempts to link a session file with hardlink first and symlink fallback. + */ function tryLinkSystemCodexSessionFile(sourcePath: string, targetPath: string): boolean { if (tryHardlinkSystemCodexSessionFile(sourcePath, targetPath)) { return true @@ -109,6 +174,9 @@ function tryLinkSystemCodexSessionFile(sourcePath: string, targetPath: string): return false } +/** + * Attempts a hardlink so resume sees one physical JSONL session log. + */ function tryHardlinkSystemCodexSessionFile(sourcePath: string, targetPath: string): boolean { try { // Why: Codex resume ignores symlinked JSONL sessions, while a hardlink @@ -120,6 +188,10 @@ function tryHardlinkSystemCodexSessionFile(sourcePath: string, targetPath: strin } } +/** + * Replaces an older symlink bridge with a hardlink when the target still points + * at the expected source session. + */ function replaceSymlinkSessionBridgeWithHardlink( sourcePath: string, targetPath: string, @@ -160,6 +232,10 @@ function replaceSymlinkSessionBridgeWithHardlink( return false } +/** + * Migrates a legacy copied bridge to a linked bridge when the copied file still + * matches its marker. + */ function migrateLegacyCopiedSessionBridge( sourcePath: string, targetPath: string, @@ -198,6 +274,12 @@ function migrateLegacyCopiedSessionBridge( } } +/** + * Resolves how scanners should treat a legacy copied session bridge. + * + * The result keeps resume scans coherent until the copied bridge is migrated to + * a hardlink or symlink. + */ export function getLegacyCopiedCodexSessionBridgeScanPreference( sessionFilePath: string ): LegacyCopiedCodexSessionBridgeScanPreference | null { @@ -234,10 +316,16 @@ export function getLegacyCopiedCodexSessionBridgeScanPreference( } } +/** + * Returns the marker path for a legacy copied session bridge. + */ function getLegacySessionCopyMarkerPath(relativePath: string): string { return join(getOrcaManagedCodexHomePath(), '.orca-session-copies', `${relativePath}.json`) } +/** + * Reads and validates the marker for a legacy copied session bridge. + */ function readLegacyCopiedSessionMarker(relativePath: string): LegacyCopiedSessionMarker | null { try { const parsed: unknown = JSON.parse( @@ -262,6 +350,9 @@ function readLegacyCopiedSessionMarker(relativePath: string): LegacyCopiedSessio } } +/** + * Checks whether source or target file stats still match a legacy bridge marker. + */ function fileStatsMatchMarker( stat: { size: number; mtimeMs: number }, marker: LegacyCopiedSessionMarker, @@ -272,6 +363,9 @@ function fileStatsMatchMarker( return stat.size === expectedSize && stat.mtimeMs === expectedMtimeMs } +/** + * Removes the marker after a copied session bridge has been migrated or retired. + */ function clearLegacyCopiedSessionMarker(relativePath: string): void { rmSync(getLegacySessionCopyMarkerPath(relativePath), { force: true }) } diff --git a/src/main/codex/codex-session-file-listing.ts b/src/main/codex/codex-session-file-listing.ts new file mode 100644 index 00000000000..c6eeb9b1f77 --- /dev/null +++ b/src/main/codex/codex-session-file-listing.ts @@ -0,0 +1,99 @@ +import { readdirSync } from 'node:fs' +import { opendir } from 'node:fs/promises' +import { join } from 'node:path' + +export type CodexSessionBridgeIncrementalOptions = { + /** Directory entries to process before yielding back to the event loop. */ + batchSize?: number + /** Delay after each processed batch; zero still yields on a timer turn. */ + yieldMs?: number +} + +const INCREMENTAL_BRIDGE_BATCH_SIZE = 64 +const INCREMENTAL_BRIDGE_YIELD_MS = 10 + +/** + * Recursively lists session JSONL files below a root directory. + * + * This synchronous variant preserves the historical bridge behavior for callers + * that run outside the CLI launch path. + */ +export function listCodexSessionJsonlFiles(rootPath: string): string[] { + const files: string[] = [] + try { + for (const entry of readdirSync(rootPath, { withFileTypes: true })) { + const childPath = join(rootPath, entry.name) + if (entry.isDirectory()) { + appendSessionFilePaths(files, listCodexSessionJsonlFiles(childPath)) + continue + } + if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(childPath) + } + } + } catch (error) { + console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) + } + return files.sort() +} + +/** + * Appends session paths without spreading large arrays into a single call. + */ +function appendSessionFilePaths(target: string[], source: readonly string[]): void { + // Why: existing Codex homes can accumulate enough nested sessions to exceed + // V8's argument limit if child arrays are spread into push(). + for (const filePath of source) { + target.push(filePath) + } +} + +/** + * Yields session JSONL files incrementally while walking a directory tree. + * + * The generator yields control between batches so large history directories do + * not monopolize startup work. + */ +export async function* listCodexSessionJsonlFilesIncrementally( + rootPath: string, + options: CodexSessionBridgeIncrementalOptions +): AsyncGenerator { + const batchSize = Math.max(1, options.batchSize ?? INCREMENTAL_BRIDGE_BATCH_SIZE) + const yieldMs = Math.max(0, options.yieldMs ?? INCREMENTAL_BRIDGE_YIELD_MS) + const pendingDirectories = [rootPath] + let entriesSinceYield = 0 + + while (pendingDirectories.length > 0) { + const currentDirectory = pendingDirectories.pop() + if (!currentDirectory) { + continue + } + try { + const directory = await opendir(currentDirectory) + for await (const entry of directory) { + const childPath = join(currentDirectory, entry.name) + if (entry.isDirectory()) { + pendingDirectories.push(childPath) + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + yield childPath + } + entriesSinceYield += 1 + if (entriesSinceYield >= batchSize) { + entriesSinceYield = 0 + await delayIncrementalBridge(yieldMs) + } + } + } catch (error) { + console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) + } + } +} + +/** + * Defers incremental bridge work to a later timer turn. + */ +function delayIncrementalBridge(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index d42d7606013..dc0dc060a62 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -758,7 +758,7 @@ describe('CodexHookService', () => { hooks: Record } expect(systemHooks.hooks.Stop).toBeUndefined() - }, 15_000) + }, 30_000) it('removes the legacy Orca Codex profile file when it only contains managed hooks', () => { const systemCodexHome = join(tmpHome, '.codex') diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index ada8a4f3298..cadff9b5e98 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1269,6 +1269,65 @@ describe('createPtySubprocess', () => { ) }) + it('embeds short PowerShell startup commands in the Windows shell launch', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + let handle: ReturnType + try { + handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + shellOverride: 'powershell.exe', + command: "& 'codex' '--no-alt-screen'" + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const lastCall = spawnMock.mock.calls.at(-1)! + const encoded = String(lastCall[1][3]) + const command = Buffer.from(encoded, 'base64').toString('utf16le') + expect(command.trimEnd().endsWith("& 'codex' '--no-alt-screen'")).toBe(true) + expect(handle!.startupCommandDeliveredInShellArgs).toBe(true) + }) + + it('keeps oversized Windows startup commands on PTY stdin delivery', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + let handle: ReturnType + try { + handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + shellOverride: 'cmd.exe', + command: `codex ${'x'.repeat(7000)}` + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'cmd.exe', + ['/K', 'chcp 65001 > nul'], + expect.any(Object) + ) + expect(handle!.startupCommandDeliveredInShellArgs).toBeUndefined() + }) + it('launches Git Bash with login args and CHERE_INVOKING on Windows', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 60f046a8eab..769797ee94d 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -64,6 +64,9 @@ export type PtySubprocessOptions = { terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' } +/** + * Returns a stable default working directory for daemon-spawned PTYs. + */ function getDefaultCwd(): string { if (process.platform !== 'win32') { return process.env.HOME || '/' @@ -81,6 +84,9 @@ function getDefaultCwd(): string { return 'C:\\' } +/** + * Removes pane identity inherited from the daemon parent unless explicitly set. + */ function removeUnspecifiedPaneIdentityEnv( env: Record, explicitEnv: Record | undefined @@ -92,6 +98,9 @@ function removeUnspecifiedPaneIdentityEnv( } } +/** + * Promotes the agent-teams shim path ahead of inherited PATH entries. + */ function promoteAgentTeamsShimPath( env: Record, requestedPath: string | undefined @@ -107,6 +116,9 @@ function promoteAgentTeamsShimPath( env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(delimiter) } +/** + * Removes stale development hook endpoints inherited by daemon children. + */ function removeInheritedDevAgentHookEndpoint( env: Record, explicitEnv: Record | undefined @@ -119,6 +131,9 @@ function removeInheritedDevAgentHookEndpoint( } } +/** + * Resolves a WSL launch context from a user-selected distro name. + */ function getWslContextFromPreferredDistro( distro: string | null | undefined ): { distro: string } | undefined { @@ -126,12 +141,18 @@ function getWslContextFromPreferredDistro( return trimmed ? { distro: trimmed } : undefined } +/** + * Strips Electron's internal run-as-node flag from user shell environments. + */ function removeInheritedElectronRunAsNode(env: Record): void { // Why: the daemon needs ELECTRON_RUN_AS_NODE=1 internally, but user shells // must not inherit it or nested Electron commands run as plain Node. delete env.ELECTRON_RUN_AS_NODE } +/** + * Formats a daemon preflight failure with the same ENOENT details node-pty exposes. + */ function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { const detailName = kind === 'helper' ? 'helper' : 'cwd' const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' @@ -142,6 +163,9 @@ function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): Dae ) } +/** + * Checks whether a path currently exists and is a directory. + */ function isExistingDirectory(path: string | undefined): path is string { if (!path) { return false @@ -153,6 +177,9 @@ function isExistingDirectory(path: string | undefined): path is string { } } +/** + * Moves the daemon process to a stable cwd after its original cwd disappears. + */ function repairDaemonCwd(): string | null { const candidates = [ process.env.ORCA_USER_DATA_PATH, @@ -172,6 +199,9 @@ function repairDaemonCwd(): string | null { return null } +/** + * Ensures the daemon cwd is valid before native PTY spawning. + */ function preflightDaemonCwd(): void { let daemonCwd = '' try { @@ -192,6 +222,9 @@ function preflightDaemonCwd(): void { throw formatMissingDaemonPathError('cwd', daemonCwd) } +/** + * Validates macOS node-pty helper availability before spawning terminals. + */ function preflightMacNodePtySpawnEnvironment(): void { if (process.platform !== 'darwin') { return @@ -219,10 +252,16 @@ function preflightMacNodePtySpawnEnvironment(): void { throw formatMissingDaemonPathError('helper', candidates[0] ?? '') } +/** + * Detects native Windows paths that should be validated before spawn. + */ function isNativeWindowsPath(path: string): boolean { return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\') } +/** + * Validates explicit native Windows cwd paths before ConPTY launch. + */ function preflightWindowsPtySpawnEnvironment(args: { validationCwd: string cwdWasExplicit: boolean @@ -238,6 +277,9 @@ function preflightWindowsPtySpawnEnvironment(args: { validateWorkingDirectory(args.validationCwd) } +/** + * Wraps native PTY spawn failures with shell and cwd context. + */ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): Error { const message = err instanceof Error ? err.message : String(err) const formatted = new DaemonProtocolError( @@ -249,6 +291,9 @@ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): return formatted } +/** + * Runs a short native PTY spawn probe for daemon health checks. + */ export async function checkPtySpawnHealth(): Promise { if (process.platform !== 'darwin') { return @@ -318,6 +363,9 @@ export async function checkPtySpawnHealth(): Promise { }) } +/** + * Normalizes node-pty foreground process strings to executable basenames. + */ function normalizeForegroundProcessName(processName: string | null | undefined): string | null { const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' if (!trimmed || trimmed === 'xterm-256color') { @@ -326,6 +374,9 @@ function normalizeForegroundProcessName(processName: string | null | undefined): return trimmed.split(/[\\/]/).pop() || null } +/** + * Falls back to the spawned Windows shell when node-pty reports a terminal name. + */ function resolveFallbackForegroundProcess( processName: string | null | undefined, shellPath: string @@ -339,6 +390,12 @@ function resolveFallbackForegroundProcess( return normalizeForegroundProcessName(pathWin32.basename(shellPath)) } +/** + * Spawns the daemon-owned PTY subprocess for a terminal session. + * + * The returned handle records whether the startup command was already embedded + * in Windows shell args so the daemon host does not write it a second time. + */ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandle { const size = normalizePtySize(opts.cols, opts.rows) const env: Record = { @@ -392,6 +449,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl let shellPath = cwdWslInfo || sessionWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) let shellArgs: string[] + let startupCommandDeliveredInShellArgs = false const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command) const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' const requestedCwd = opts.cwd || getDefaultCwd() @@ -436,11 +494,13 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl shellPath, spawnCwd, getDefaultCwd(), - sessionWslContext ?? preferredWslContext + sessionWslContext ?? preferredWslContext, + opts.command ) shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true if (isWindowsGitBashShellPath(shellPath)) { // Why: Git for Windows login startup files otherwise cd to $HOME, // ignoring node-pty's cwd for repo-scoped terminals. @@ -466,11 +526,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl getDefaultCwd(), { distro: codexHomeWslInfo.distro - } + }, + opts.command ) shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = + resolved.startupCommandDeliveredInShellArgs === true } } } else if (isHostCodexHomeForWsl(env.CODEX_HOME)) { @@ -683,6 +746,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return { pid: proc.pid, + ...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), getForegroundProcess: () => { // Why: node-pty's `.process` getter reports the PTY's live foreground // process name (the agent running in the shell, or the shell itself) and diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 94e434d7fac..f3262b4eccd 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -31,6 +31,9 @@ export type SubprocessHandle = { /** Live foreground process name of the PTY (node-pty's `.process`), e.g. * 'claude' / 'codex' / 'zsh'. Null once the child has exited. */ getForegroundProcess(): string | null + /** True when shell launch args already delivered the startup command, so the + * terminal host must skip its stdin fallback write. */ + startupCommandDeliveredInShellArgs?: boolean write(data: string): void resize(cols: number, rows: number): void kill(): void diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index a0c4b9579eb..f26aa6817d7 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -3,11 +3,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TerminalHost } from './terminal-host' import type { SubprocessHandle } from './session' -function createMockSubprocess(): SubprocessHandle { +function createMockSubprocess( + options: { startupCommandDeliveredInShellArgs?: boolean } = {} +): SubprocessHandle { let onDataCb: ((data: string) => void) | null = null let onExitCb: ((code: number) => void) | null = null return { pid: 99999, + ...(options.startupCommandDeliveredInShellArgs + ? { startupCommandDeliveredInShellArgs: true } + : {}), getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), @@ -163,6 +168,31 @@ describe('TerminalHost', () => { process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n' ) }) + + it('does not write startup commands already embedded in shell args', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ + startupCommandDeliveredInShellArgs: true + }) as ReturnType & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'codex --no-alt-screen', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(lastSubprocess.write).not.toHaveBeenCalled() + }) }) describe('write', () => { diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 2bb4473b45b..a772de6ef3a 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -77,6 +77,12 @@ export class TerminalHost { this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES } + /** + * Creates a terminal session or attaches to an existing live one. + * + * Startup commands are written through stdin only when the subprocess did not + * already deliver them through shell launch arguments. + */ async createOrAttach(opts: CreateOrAttachOptions): Promise { const existing = this.sessions.get(opts.sessionId) @@ -137,7 +143,7 @@ export class TerminalHost { const token = session.attachClient(opts.streamClient) - if (opts.command) { + if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) { // Why: startup commands must run inside the long-lived interactive shell // the daemon keeps for the pane. Session.write() handles the shell-ready // barrier for supported shells and falls back to an immediate write for diff --git a/src/main/devin/hook-service.test.ts b/src/main/devin/hook-service.test.ts index 46e1b2b4d0f..e7b90c7fcc1 100644 --- a/src/main/devin/hook-service.test.ts +++ b/src/main/devin/hook-service.test.ts @@ -16,7 +16,11 @@ vi.mock('os', async () => { }) import { DevinHookService } from './hook-service' -import { getDevinConfigPath, getDevinManagedCommand } from './hook-settings' +import { + getDevinConfigPath, + getDevinManagedCommand, + getDevinManagedScriptFileName +} from './hook-settings' describe('DevinHookService', () => { let homeDir: string @@ -24,9 +28,11 @@ describe('DevinHookService', () => { beforeEach(() => { homeDir = mkdtempSync(join(tmpdir(), 'orca-devin-home-')) homedirMock.mockReturnValue(homeDir) + vi.stubEnv('APPDATA', join(homeDir, '.config')) }) afterEach(() => { + vi.unstubAllEnvs() vi.clearAllMocks() rmSync(homeDir, { recursive: true, force: true }) }) @@ -57,7 +63,10 @@ describe('DevinHookService', () => { for (const eventName of ['PreToolUse', 'PostToolUse', 'PermissionRequest']) { expect(config.hooks[eventName][0].matcher).toBeUndefined() } - const script = readFileSync(join(homeDir, '.orca', 'agent-hooks', 'devin-hook.sh'), 'utf8') + const script = readFileSync( + join(homeDir, '.orca', 'agent-hooks', getDevinManagedScriptFileName()), + 'utf8' + ) expect(script).toContain('/hook/devin') }) @@ -155,7 +164,7 @@ describe('DevinHookService', () => { it('returns partial status when some managed hooks are missing', () => { const configPath = join(homeDir, '.config', 'devin', 'config.json') - const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'devin-hook.sh') + const scriptPath = join(homeDir, '.orca', 'agent-hooks', getDevinManagedScriptFileName()) const command = getDevinManagedCommand(scriptPath) mkdirSync(dirname(configPath), { recursive: true }) mkdirSync(dirname(scriptPath), { recursive: true }) diff --git a/src/main/ipc/pty-startup-barrier-ordering.test.ts b/src/main/ipc/pty-startup-barrier-ordering.test.ts index 0de91770d37..38926946639 100644 --- a/src/main/ipc/pty-startup-barrier-ordering.test.ts +++ b/src/main/ipc/pty-startup-barrier-ordering.test.ts @@ -4,7 +4,10 @@ import { describe, expect, it } from 'vitest' describe('PTY startup barrier ordering', () => { it('waits for local startup before resolving the provider for runtime and renderer spawns', () => { - const source = readFileSync(join(process.cwd(), 'src/main/ipc/pty.ts'), 'utf8') + const source = readFileSync(join(process.cwd(), 'src/main/ipc/pty.ts'), 'utf8').replaceAll( + '\r\n', + '\n' + ) const runtimeSpawnStart = source.indexOf('spawn: async (args) => {') const runtimeSpawnEnd = source.indexOf(' write:', runtimeSpawnStart) const runtimeSpawn = source.slice(runtimeSpawnStart, runtimeSpawnEnd) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index d978cac64ac..e27685f05b0 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6,11 +6,7 @@ import { delimiter, join } from 'node:path' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it -const expectedOmpStatusExtension = join( - '/tmp/default-omp-agent', - 'extensions', - 'orca-agent-status.ts' -) +const expectedOmpStatusExtension = '/tmp/default-omp-agent/extensions/orca-agent-status.ts' const expectedAttributionShimDir = join( '/tmp/orca-user-data', 'orca-terminal-attribution', diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 0b7da3deba4..1425483906f 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -65,6 +65,9 @@ type ExitCallback = (payload: { id: string; code: number }) => void const dataListeners = new Set() const exitListeners = new Set() +/** + * Returns a stable default cwd for locally spawned PTYs. + */ function getDefaultCwd(): string { if (process.platform !== 'win32') { return process.env.HOME || '/' @@ -82,6 +85,9 @@ function getDefaultCwd(): string { return 'C:\\' } +/** + * Removes inherited pane identity unless this PTY explicitly supplies it. + */ function removeUnspecifiedPaneIdentityEnv( env: Record, explicitEnv: Record | undefined @@ -93,6 +99,9 @@ function removeUnspecifiedPaneIdentityEnv( } } +/** + * Promotes the agent-teams shim path ahead of inherited PATH entries. + */ function promoteAgentTeamsShimPath( env: Record, requestedPath: string | undefined @@ -108,6 +117,9 @@ function promoteAgentTeamsShimPath( env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(delimiter) } +/** + * Disposes native node-pty listeners registered for a PTY id. + */ function disposePtyListeners(id: string): void { const disposables = ptyDisposables.get(id) if (disposables) { @@ -118,6 +130,9 @@ function disposePtyListeners(id: string): void { } } +/** + * Resolves a WSL context from a worktree id whose path is already a WSL path. + */ function getWslContextFromWorktreeId( worktreeId: string | undefined ): { distro: string; treatPosixCwdAsWsl: true } | undefined { @@ -126,6 +141,9 @@ function getWslContextFromWorktreeId( return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined } +/** + * Resolves a WSL launch context from a user-selected distro name. + */ function getWslContextFromPreferredDistro( distro: string | null | undefined ): { distro: string } | undefined { @@ -133,6 +151,9 @@ function getWslContextFromPreferredDistro( return trimmed ? { distro: trimmed } : undefined } +/** + * Removes all local tracking state for a PTY id after teardown. + */ function clearPtyState(id: string): void { disposePtyListeners(id) ptyProcesses.delete(id) @@ -141,6 +162,9 @@ function clearPtyState(id: string): void { ptyLoadGeneration.delete(id) } +/** + * Allocates either a stable caller-provided PTY id or a new numeric id. + */ function allocatePtyId(sessionId: string | undefined): string { const requested = normalizeLocalCallerSessionId(sessionId) if (requested) { @@ -153,6 +177,9 @@ function allocatePtyId(sessionId: string | undefined): string { return id } +/** + * Normalizes renderer session ids that should be reused for local PTY reattach. + */ function normalizeLocalCallerSessionId(sessionId: string | undefined): string | null { const requested = sessionId?.trim() if (!requested || /^\d+$/.test(requested)) { @@ -161,6 +188,9 @@ function normalizeLocalCallerSessionId(sessionId: string | undefined): string | return requested } +/** + * Normalizes node-pty foreground process strings to executable basenames. + */ function normalizeForegroundProcessName(processName: string | null | undefined): string | null { const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' if (!trimmed || trimmed === 'xterm-256color') { @@ -169,6 +199,9 @@ function normalizeForegroundProcessName(processName: string | null | undefined): return trimmed.split(/[\\/]/).pop() || null } +/** + * Falls back to the spawned Windows shell when node-pty reports a terminal name. + */ function resolveForegroundFallbackProcess( processName: string | null | undefined, shellName: string | undefined @@ -181,6 +214,9 @@ function resolveForegroundFallbackProcess( return shellName ?? processName ?? null } +/** + * Disposes the native PTY handle while avoiding recycled-pid signals on POSIX. + */ function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } = {}): void { // Why: node-pty's UnixTerminal.destroy() closes the master socket, which // releases the ptmx fd to the OS — without this call the fd leaks until GC @@ -204,6 +240,9 @@ function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } } } +/** + * Kills a local PTY and clears all associated local provider state. + */ function safeKillAndClean(id: string, proc: pty.IPty): void { disposePtyListeners(id) try { @@ -252,6 +291,12 @@ export class LocalPtyProvider implements IPtyProvider { this.opts = opts } + /** + * Spawns or reattaches a local PTY session for the renderer process. + * + * Windows shell launches can pre-deliver short startup commands in argv; this + * method preserves that state so the stdin fallback only runs when needed. + */ async spawn(args: PtySpawnOptions): Promise { const reattachId = normalizeLocalCallerSessionId(args.sessionId) if (reattachId) { @@ -281,6 +326,7 @@ export class LocalPtyProvider implements IPtyProvider { let shellArgs: string[] let effectiveCwd: string let validationCwd: string + let startupCommandDeliveredInShellArgs = false let shellReadyLaunch: ReturnType | null = null let getFallbackShellReadyConfig: | ((shell: string) => ReturnType) @@ -340,11 +386,13 @@ export class LocalPtyProvider implements IPtyProvider { shellPath, cwd, defaultCwd, - worktreeWslContext ?? preferredWslContext + worktreeWslContext ?? preferredWslContext, + args.command ) shellArgs = resolved.shellArgs effectiveCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true } else { shellPath = args.env?.SHELL || process.env.SHELL || '/bin/zsh' shellArgs = ['-l'] @@ -441,6 +489,8 @@ export class LocalPtyProvider implements IPtyProvider { shellArgs = resolved.shellArgs effectiveCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = + resolved.startupCommandDeliveredInShellArgs === true } } } else if (isHostCodexHomeForWsl(finalEnv.CODEX_HOME)) { @@ -651,7 +701,7 @@ export class LocalPtyProvider implements IPtyProvider { } ptyDisposables.set(id, disposables) - if (args.command) { + if (args.command && !startupCommandDeliveredInShellArgs) { writeStartupCommandWhenShellReady(shellReadyPromise, proc, args.command, (cleanup) => { startupCommandCleanup = cleanup }) diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 46d825dc7fa..0801f002ed5 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -19,10 +19,35 @@ describe('resolveWindowsShellLaunchArgs', () => { it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => { const result = resolveWindowsShellLaunchArgs('cmd.exe', 'C:\\Users\\alice', 'C:\\Users\\alice') expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul']) + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() expect(result.effectiveCwd).toBe('C:\\Users\\alice') expect(result.validationCwd).toBe('C:\\Users\\alice') }) + it('embeds short cmd.exe startup commands in shell args', () => { + const result = resolveWindowsShellLaunchArgs( + 'cmd.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + 'codex --no-alt-screen' + ) + expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul & codex --no-alt-screen']) + expect(result.startupCommandDeliveredInShellArgs).toBe(true) + }) + + it('keeps large cmd.exe startup commands on stdin delivery', () => { + const result = resolveWindowsShellLaunchArgs( + 'cmd.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + `codex ${'x'.repeat(7000)}` + ) + expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul']) + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() + }) + it('returns PowerShell args that install OSC 133 bootstrap after normal profile loading', () => { const result = resolveWindowsShellLaunchArgs( 'powershell.exe', @@ -64,6 +89,56 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(command).not.toContain('`e]133') }) + it('embeds short PowerShell startup commands after the OSC 133 bootstrap', () => { + const result = resolveWindowsShellLaunchArgs( + 'powershell.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + "& 'codex' '--no-alt-screen'" + ) + expect(result.startupCommandDeliveredInShellArgs).toBe(true) + + const command = Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le') + expect(command).toContain('function Global:prompt') + expect(command.trimEnd().endsWith("& 'codex' '--no-alt-screen'")).toBe(true) + }) + + it('preserves complex PowerShell startup command text through EncodedCommand', () => { + const startupCommand = + '& "C:\\Program Files\\Orca CLI\\orca.exe" "--label" "quoted value"; $env:ORCA_VALUE = "nested"' + const result = resolveWindowsShellLaunchArgs( + 'powershell.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + startupCommand + ) + + expect(result.startupCommandDeliveredInShellArgs).toBe(true) + const command = Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le') + expect(command).toContain(`\n${startupCommand}`) + expect(command.trimEnd().endsWith(startupCommand)).toBe(true) + }) + + it('keeps large PowerShell startup commands on stdin delivery', () => { + const result = resolveWindowsShellLaunchArgs( + 'powershell.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + `orca ${'x'.repeat(7000)}` + ) + + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() + expect(result.shellArgs).toEqual([ + '-NoLogo', + '-NoExit', + '-EncodedCommand', + encodePowerShellCommand(getPowerShellOsc133Bootstrap()) + ]) + }) + it('handles pwsh.exe (PowerShell Core) the same as Windows PowerShell', () => { const result = resolveWindowsShellLaunchArgs('pwsh.exe', 'C:\\', 'C:\\Users\\alice') expect(result.shellArgs).toEqual([ @@ -102,9 +177,12 @@ describe('resolveWindowsShellLaunchArgs', () => { const result = resolveWindowsShellLaunchArgs( 'wsl.exe', 'C:\\Users\\alice\\code', - 'C:\\Users\\alice' + 'C:\\Users\\alice', + undefined, + 'codex' ) expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code')) + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() // Why: WSL cannot cd into a Windows path, so node-pty must start from the // user's Windows home and we inject the Linux cd into the shellArgs above. expect(result.effectiveCwd).toBe('C:\\Users\\alice') diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index b41fbd55788..9944b127745 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -11,6 +11,11 @@ import { getPowerShellOsc133Bootstrap } from '../powershell-osc133-bootstrap' +const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 +const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000 +const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000 +const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul' + /** Result of resolving a Windows shell to its launch args + effective cwd. * * Why this module exists: both the in-process LocalPtyProvider and the @@ -21,6 +26,9 @@ import { * decision here keeps both paths honest. */ export type WindowsShellLaunchArgs = { shellArgs: string[] + /** True when the startup command was embedded in shellArgs and must not be + * written again through stdin. */ + startupCommandDeliveredInShellArgs?: boolean /** The cwd node-pty should be spawned with. WSL cannot cd into a Windows * path, so the wsl.exe branch returns the user's home as the effective cwd * and injects `cd ''` into shellArgs instead. */ @@ -36,6 +44,55 @@ export type WindowsShellWslContext = { treatPosixCwdAsWsl?: boolean } +/** + * Returns a startup command that is safe to embed in cmd.exe launch args. + * + * Commands that could exceed Windows cmd.exe limits return null so callers + * keep the older stdin delivery path. + */ +function getCmdShellArgStartupCommand(command?: string): string | null { + if (!command || command.length > STARTUP_COMMAND_TEXT_MAX_CHARS) { + return null + } + const commandArg = `${CMD_UTF8_SETUP_COMMAND} & ${command}` + if (commandArg.length > CMD_EXE_COMMAND_LINE_MAX_CHARS) { + return null + } + return command +} + +/** + * Builds the PowerShell -EncodedCommand payload for startup bootstrap. + * + * Short startup commands are appended to the bootstrap and marked as delivered; + * large payloads return the bootstrap alone so stdin delivery remains available. + */ +function getPowerShellEncodedCommand(startupCommand?: string): { + encodedCommand: string + startupCommandDeliveredInShellArgs?: boolean +} { + const bootstrap = getPowerShellOsc133Bootstrap() + if (!startupCommand || startupCommand.length > STARTUP_COMMAND_TEXT_MAX_CHARS) { + return { encodedCommand: encodePowerShellCommand(bootstrap) } + } + + const command = `${bootstrap}\n${startupCommand}` + const encodedCommand = encodePowerShellCommand(command) + // Why: -EncodedCommand expands UTF-16 text into base64; keep a conservative + // margin under Windows CreateProcess' 32,767-character command line limit. + if (encodedCommand.length > POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS) { + return { encodedCommand: encodePowerShellCommand(bootstrap) } + } + + return { + encodedCommand, + startupCommandDeliveredInShellArgs: true + } +} + +/** + * Builds wsl.exe arguments that enter the target directory through the distro shell. + */ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { const setupCommand = [ `cd ${quotePosixShell(linuxCwd)}`, @@ -61,28 +118,35 @@ export function resolveWindowsShellLaunchArgs( shellPath: string, cwd: string, defaultCwd: string, - wslContext?: WindowsShellWslContext + wslContext?: WindowsShellWslContext, + startupCommand?: string ): WindowsShellLaunchArgs { const shellBasename = pathWin32.basename(shellPath).toLowerCase() if (shellBasename === 'cmd.exe') { + const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand) return { - shellArgs: ['/K', 'chcp 65001 > nul'], + shellArgs: [ + '/K', + shellArgStartupCommand + ? `${CMD_UTF8_SETUP_COMMAND} & ${shellArgStartupCommand}` + : CMD_UTF8_SETUP_COMMAND + ], + ...(shellArgStartupCommand ? { startupCommandDeliveredInShellArgs: true } : {}), effectiveCwd: cwd, validationCwd: cwd } } if (shellBasename === 'powershell.exe' || shellBasename === 'pwsh.exe') { + const powerShellCommand = getPowerShellEncodedCommand(startupCommand) // Why: foreground-process status on Windows depends on OSC 133 C/D, and // PowerShell needs a prompt/readline bootstrap after profiles finish. return { - shellArgs: [ - '-NoLogo', - '-NoExit', - '-EncodedCommand', - encodePowerShellCommand(getPowerShellOsc133Bootstrap()) - ], + shellArgs: ['-NoLogo', '-NoExit', '-EncodedCommand', powerShellCommand.encodedCommand], + ...(powerShellCommand.startupCommandDeliveredInShellArgs + ? { startupCommandDeliveredInShellArgs: true } + : {}), effectiveCwd: cwd, validationCwd: cwd } diff --git a/src/relay/git-handler-branch-cleanup.test.ts b/src/relay/git-handler-branch-cleanup.test.ts index 54e497ca9a8..430076e8482 100644 --- a/src/relay/git-handler-branch-cleanup.test.ts +++ b/src/relay/git-handler-branch-cleanup.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import * as path from 'path' import type { GitExec } from './git-handler-ops' import { removeWorktreeOp } from './git-handler-worktree-ops' @@ -16,7 +15,7 @@ function worktreeList(...entries: { path: string; branch?: string }[]): string { } function resolvedRepoPath(): string { - return path.resolve('/repo-feature', '/repo/.git', '..') + return '/repo' } describe('removeWorktreeOp branch cleanup', () => { diff --git a/src/relay/git-handler-pull-reconciliation.test.ts b/src/relay/git-handler-pull-reconciliation.test.ts index 2803301d5ef..7312d295dde 100644 --- a/src/relay/git-handler-pull-reconciliation.test.ts +++ b/src/relay/git-handler-pull-reconciliation.test.ts @@ -94,7 +94,7 @@ describe('GitHandler pull reconciliation', () => { ) expect(execGit(consumerDir, ['status', '--short'])).toBe('') - }) + }, 15_000) it('preserves configured rebase pull semantics', async () => { const consumerDir = createDivergentFixture() @@ -108,7 +108,7 @@ describe('GitHandler pull reconciliation', () => { expect(parentRefs).toHaveLength(1) expect(existsSync(path.join(consumerDir, 'remote.txt'))).toBe(true) expect(execGit(consumerDir, ['status', '--short'])).toBe('') - }) + }, 15_000) }) function restoreGitEnv( diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts index 177661f7114..684bb5e2bdc 100644 --- a/src/relay/git-handler-worktree-ops.test.ts +++ b/src/relay/git-handler-worktree-ops.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import * as path from 'path' import type { GitExec } from './git-handler-ops' import { addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops' @@ -16,7 +15,7 @@ function worktreeList(...entries: { path: string; branch?: string }[]): string { } function resolvedRepoPath(): string { - return path.resolve('/repo-feature', '/repo/.git', '..') + return '/repo' } describe('addWorktreeOp', () => { diff --git a/src/relay/git-handler-worktree-paths.test.ts b/src/relay/git-handler-worktree-paths.test.ts index d2f9ba9e4cc..d46d1918300 100644 --- a/src/relay/git-handler-worktree-paths.test.ts +++ b/src/relay/git-handler-worktree-paths.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import * as path from 'path' import type { GitExec } from './git-handler-ops' import { removeWorktreeOp } from './git-handler-worktree-ops' @@ -29,7 +28,7 @@ function nulWorktreeList(...entries: { path: string; branch?: string }[]): strin } function resolvedRepoPath(): string { - return path.resolve('/repo-feature', '/repo/.git', '..') + return '/repo' } describe('relay worktree path parsing', () => { diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts index 50394129b92..0aa39198129 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts @@ -2,7 +2,10 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -const FIELD_SOURCE = readFileSync(join(__dirname, 'SmartWorkspaceNameField.tsx'), 'utf8') +const FIELD_SOURCE = readFileSync( + join(__dirname, 'SmartWorkspaceNameField.tsx'), + 'utf8' +).replaceAll('\r\n', '\n') function sourceBetween(source: string, startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index c3e8870cbe7..3d7e25d7362 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -226,7 +226,7 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('58941') expect(markup).not.toContain('data-worktree-card-meta-row=""') expect(markup).toContain('aria-label="1 live port"') - }, 20_000) + }, 30_000) it('shows hidden task, notes, and port details from the compact worktree card hover', async () => { settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } @@ -277,7 +277,7 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('Live Ports') expect(markup).toContain('58941') expect(markup).not.toContain('data-worktree-card-meta-row=""') - }, 20_000) + }, 30_000) it('shows selected task and note metadata on the compact card title row', async () => { settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } @@ -300,7 +300,7 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('Linked issue #123') expect(markup).toContain('Linked Linear ENG-123') expect(markup).toContain('Workspace notes') - }, 20_000) + }, 30_000) it('keeps selected task and note metadata above the compact branch row', async () => { settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } @@ -327,7 +327,7 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('Linked Linear ENG-123') expect(markup).toContain('Workspace notes') expect(markup).toContain('feature/local-branch') - }, 20_000) + }, 30_000) it('keeps branch identity visible on detailed cards by default', async () => { settings = { compactWorktreeCards: false } diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx index 1d289a0f1e8..c28bfe96813 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx @@ -157,7 +157,7 @@ describe('WorktreeCardAgents send targets', () => { expect(markup).toContain('data-disabled-reason="Agent is working"') expect(markup).toContain(`data-pane-key="${WORKING_PANE_KEY}"`) expect(markup).toContain('data-has-send-handler="true"') - }, 10_000) + }, 30_000) it('leaves other worktree rows in ordinary mode during target selection', async () => { mockStoreState = { diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index 2c3cf82729e..5981b049a1b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -191,7 +191,7 @@ describe('WorktreeCardAgents', () => { expect(markup).toContain('data-testid="agent-row"') expect(markup).not.toContain(' { mockAgents = [mockAgent({ agentType: 'codex', startedAt: 1000, prompt: 'Run tests' })] diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index 4c587e49aa6..7a5dfbb13f9 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -386,7 +386,7 @@ function expectBoundaryStep(args: { describe('WorktreeList real child WorktreeCard integration', () => { beforeAll(async () => { WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent - }, 20_000) + }, 60_000) beforeEach(() => { vi.clearAllMocks() diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index e317d7c24af..1c40881b691 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -195,7 +195,7 @@ async function renderExpandedBrowserTab(tab: BrowserTabState): Promise return expandNode(await renderBrowserTab(tab)) } -describe('BrowserTab favicon', { timeout: 10_000 }, () => { +describe('BrowserTab favicon', { timeout: 30_000 }, () => { beforeEach(() => { reactHookRuntime.states = [] reactHookRuntime.index = 0 diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index a526711f0ed..4c8c34168f5 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -378,7 +378,7 @@ describe('TabBar PowerShell launch wiring', () => { onSelect?.() expect(onNewTerminalWithShell).toHaveBeenCalledWith('pwsh.exe') - }) + }, 30_000) it('hides the WSL terminal row for local host-runtime projects', async () => { appStoreSnapshot.activeRepoId = 'repo-1' 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 ba5a98d195b..883544ed778 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -596,7 +596,7 @@ describe('connectPanePty', () => { expect((globalThis as Record).__ptyConnectDiag).toBeUndefined() expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('[pty-connect]')) logSpy.mockRestore() - }) + }, 30_000) it('threads the resolved local project runtime into IPC terminal transport options', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts index b24499e98a4..3ffde420248 100644 --- a/src/shared/wsl-login-shell-command.test.ts +++ b/src/shared/wsl-login-shell-command.test.ts @@ -7,9 +7,30 @@ import { quotePosixShell } from './wsl-login-shell-command' +const WSL_TEST_COMMAND_TIMEOUT_MS = 10_000 +let wslShAvailable: boolean | null = null + +function canRunWslSh(): boolean { + if (process.platform !== 'win32') { + return false + } + if (wslShAvailable !== null) { + return wslShAvailable + } + try { + execFileSync('wsl.exe', ['--', 'sh', '-lc', 'true'], { + timeout: WSL_TEST_COMMAND_TIMEOUT_MS + }) + wslShAvailable = true + } catch { + wslShAvailable = false + } + return wslShAvailable +} + function expectValidShSyntax(command: string): void { try { - execFileSync('sh', ['-n'], { input: command }) + execFileSync('sh', ['-n'], { input: command, timeout: WSL_TEST_COMMAND_TIMEOUT_MS }) return } catch (error) { if ( @@ -19,7 +40,13 @@ function expectValidShSyntax(command: string): void { throw error } } - execFileSync('wsl.exe', ['--', 'sh', '-n'], { input: command }) + if (!canRunWslSh()) { + return + } + execFileSync('wsl.exe', ['--', 'sh', '-n'], { + input: command, + timeout: WSL_TEST_COMMAND_TIMEOUT_MS + }) } describe('wsl login shell command helpers', () => { @@ -45,7 +72,7 @@ describe('wsl login shell command helpers', () => { expect(escaped).toContain('\\$(getent passwd "\\$(id -un)"') expect(escaped).toContain('\\$HISTFILE') expectValidShSyntax(command) - }, 15_000) + }, 30_000) it('does not double-escape wrapper shell variables', () => { const command = 'echo \\$_orca_wsl_shell "$_orca_wsl_shell"' @@ -65,23 +92,23 @@ describe('wsl login shell command helpers', () => { "'HISTFILE=/tmp/orca-history printf \"\\$HISTFILE\"; printf '\\''%s'\\'' \"\\$SHELL\"'" ) expectValidShSyntax(command) - }, 15_000) + }, 30_000) it('preserves user command variables across the Windows-to-WSL argv boundary', () => { - if (process.platform !== 'win32') { - return - } - try { - execFileSync('wsl.exe', ['--', 'true']) - } catch { + if (!canRunWslSh()) { return } const command = buildWslLoginShellCommand('orca_value=ok; printf "<%s>" "$orca_value"') const escaped = escapeWslShCommandForWindows(command) - expect(execFileSync('wsl.exe', ['--', 'sh', '-lc', escaped], { encoding: 'utf8' })).toBe('') - }, 15_000) + expect( + execFileSync('wsl.exe', ['--', 'sh', '-lc', escaped], { + encoding: 'utf8', + timeout: WSL_TEST_COMMAND_TIMEOUT_MS + }) + ).toBe('') + }, 30_000) it('starts an interactive login shell without assuming bash', () => { const command = buildWslInteractiveLoginShellCommand() From f1384597f78a0d68ccb818922d49ca2423feb130 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:23:27 -0700 Subject: [PATCH 010/163] Update installed Orca skills without reinstalling (#5755) Co-authored-by: Orca --- .../feature-wall/BrowserUseSkillSetupCard.tsx | 17 +- .../FloatingTerminalOrchestrationDialog.tsx | 17 +- .../settings/AgentSkillSetupPanel.test.tsx | 223 ++++++++++++++++-- .../settings/AgentSkillSetupPanel.tsx | 39 +-- .../components/settings/BrowserUsePane.tsx | 15 +- .../settings/BrowserUseSkillStep.test.tsx | 41 ++++ .../settings/BrowserUseSkillStep.tsx | 3 + .../settings/CliAgentSkillSetup.tsx | 23 +- .../components/settings/CliSection.test.tsx | 32 ++- .../src/components/settings/CliSection.tsx | 20 +- .../settings/CliSkillRuntimeSetup.test.tsx | 13 + .../settings/CliSkillRuntimeSetup.tsx | 12 +- .../settings/ComputerUseSkillSetupPanel.tsx | 15 +- .../MobileEmulatorAgentControlRow.tsx | 6 +- .../settings/OrchestrationPane.test.tsx | 127 +++++++++- .../components/settings/OrchestrationPane.tsx | 24 +- .../settings/OrchestrationSetupCard.tsx | 17 +- ...nt-skill-installed-command-callers.test.ts | 139 +++++++++++ .../sidebar/LinearAgentSkillSetupDialog.tsx | 3 + ...ntSkillSetupPrompt.reminder-toast.test.tsx | 2 +- .../LinearAgentSkillSetupPrompt.test.tsx | 4 +- .../sidebar/LinearAgentSkillSetupPrompt.tsx | 16 +- src/renderer/src/i18n/locales/en.json | 12 +- src/renderer/src/i18n/locales/es.json | 12 +- src/renderer/src/i18n/locales/ja.json | 12 +- src/renderer/src/i18n/locales/ko.json | 12 +- src/renderer/src/i18n/locales/zh.json | 12 +- .../src/lib/agent-feature-install-commands.ts | 7 +- .../src/lib/orchestration-install-command.ts | 5 +- .../agent-feature-install-commands.test.ts | 35 +++ src/shared/agent-feature-install-commands.ts | 20 ++ 31 files changed, 812 insertions(+), 123 deletions(-) create mode 100644 src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx create mode 100644 src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts create mode 100644 src/shared/agent-feature-install-commands.test.ts diff --git a/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx b/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx index 549ea66ceeb..dd46fb2bfb2 100644 --- a/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx +++ b/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx @@ -1,5 +1,8 @@ import type { JSX } from 'react' -import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands' +import { + ORCA_CLI_SKILL_INSTALL_COMMAND, + ORCA_CLI_SKILL_UPDATE_COMMAND +} from '@/lib/agent-feature-install-commands' import { AGENT_SKILL_CLI_PREREQUISITE_NOTICE, ensureOrcaCliAvailableForAgentSkillTerminal @@ -9,7 +12,7 @@ import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { AgentSkillSetupPanel } from '@/components/settings/AgentSkillSetupPanel' import { - buildSkillInstallCommandForRuntime, + buildSkillCommandForRuntime, ensureWslCliAvailableForAgentSkillTerminal, getWslCliDistroRequest } from '@/components/settings/CliSkillRuntimeSetup' @@ -25,11 +28,12 @@ export function BrowserUseSkillSetupCard(props: { const activeSkillRuntime = useActiveProjectSkillRuntime() const installCommand = activeSkillRuntime.agentRuntime && !activeSkillRuntime.installDisabledReason - ? buildSkillInstallCommandForRuntime( - ORCA_CLI_SKILL_INSTALL_COMMAND, - activeSkillRuntime.agentRuntime - ) + ? buildSkillCommandForRuntime(ORCA_CLI_SKILL_INSTALL_COMMAND, activeSkillRuntime.agentRuntime) : ORCA_CLI_SKILL_INSTALL_COMMAND + const updateCommand = + activeSkillRuntime.agentRuntime && !activeSkillRuntime.installDisabledReason + ? buildSkillCommandForRuntime(ORCA_CLI_SKILL_UPDATE_COMMAND, activeSkillRuntime.agentRuntime) + : ORCA_CLI_SKILL_UPDATE_COMMAND const handleBeforeOpenTerminal = async (): Promise => { useAppStore.getState().recordFeatureInteraction('agent-browser-setup') @@ -51,6 +55,7 @@ export function BrowserUseSkillSetupCard(props: { "Enables agents to navigate and verify pages in Orca's browser." )} command={installCommand} + installedCommand={updateCommand} terminalTitle="Browser Use setup" terminalAriaLabel="Browser Use skill install terminal" terminalWorktreeId="feature-wall-browser-use-skill-terminal" diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx index 8ebee89490d..6c39591d049 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx @@ -13,7 +13,10 @@ import { AGENT_SKILL_CLI_PREREQUISITE_NOTICE, ensureOrcaCliAvailableForAgentSkillTerminal } from '@/lib/agent-skill-cli-prerequisite' -import { ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/orchestration-install-command' +import { + ORCHESTRATION_SKILL_INSTALL_COMMAND, + ORCHESTRATION_SKILL_UPDATE_COMMAND +} from '@/lib/orchestration-install-command' import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, useInstalledAgentSkill @@ -21,7 +24,7 @@ import { import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { useAppStore } from '@/store' import { - buildSkillInstallCommandForRuntime, + buildSkillCommandForRuntime, ensureWslCliAvailableForAgentSkillTerminal, getWslCliDistroRequest } from '@/components/settings/CliSkillRuntimeSetup' @@ -41,11 +44,18 @@ export function FloatingTerminalOrchestrationDialog({ const activeSkillRuntime = useActiveProjectSkillRuntime() const installCommand = activeSkillRuntime.agentRuntime && !activeSkillRuntime.installDisabledReason - ? buildSkillInstallCommandForRuntime( + ? buildSkillCommandForRuntime( ORCHESTRATION_SKILL_INSTALL_COMMAND, activeSkillRuntime.agentRuntime ) : ORCHESTRATION_SKILL_INSTALL_COMMAND + const updateCommand = + activeSkillRuntime.agentRuntime && !activeSkillRuntime.installDisabledReason + ? buildSkillCommandForRuntime( + ORCHESTRATION_SKILL_UPDATE_COMMAND, + activeSkillRuntime.agentRuntime + ) + : ORCHESTRATION_SKILL_UPDATE_COMMAND const { installed: orchestrationSkillDetected, loading: orchestrationSkillLoading, @@ -119,6 +129,7 @@ export function FloatingTerminalOrchestrationDialog({ 'Enables agents to hand off context and coordinate work through Orca.' )} command={installCommand} + installedCommand={updateCommand} terminalTitle="Orchestration setup" terminalAriaLabel="Orchestration skill install terminal" terminalWorktreeId="floating-terminal-orchestration-skill-terminal" diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx index 1a97bffaee8..2bb4bac748f 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx @@ -1,24 +1,64 @@ +// @vitest-environment happy-dom + +import { act, type ComponentProps } from 'react' +import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' -import type { ComponentProps } from 'react' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' +import { TooltipProvider } from '../ui/tooltip' + +const INSTALL_COMMAND = 'npx skills add https://github.com/stablyai/orca --skill orca-cli --global' +const UPDATE_COMMAND = 'npx skills update orca-cli --global' + +const mocks = vi.hoisted(() => ({ + clipboardWrite: vi.fn(), + terminalProps: [] as { command: string; description: string }[], + toastError: vi.fn(), + toastSuccess: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { + error: mocks.toastError, + success: mocks.toastSuccess + } +})) + +vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({ + OnboardingInlineCommandTerminal: (props: { command: string; description: string }) => { + mocks.terminalProps.push(props) + return ( +
+ {props.command} +
+ ) + } +})) + +function panelProps( + overrides: Partial> = {} +): ComponentProps { + return { + title: 'CLI skill', + description: 'Enables agents to use Orca workflows.', + command: INSTALL_COMMAND, + terminalTitle: 'CLI skill setup', + terminalAriaLabel: 'CLI skill install terminal', + terminalWorktreeId: 'settings-cli-skill-terminal', + installed: false, + loading: false, + error: null, + onRecheck: vi.fn(), + ...overrides + } +} function renderPanel(overrides: Partial> = {}): string { - return renderToStaticMarkup( - - ) + return renderToStaticMarkup() } function buttonLabels(html: string): string[] { @@ -36,7 +76,79 @@ function buttonMarkupByLabel(html: string, label: string): string | undefined { ) } +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderInteractivePanel( + overrides: Partial> = {} +): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await rerenderInteractivePanel(overrides) + return container +} + +async function rerenderInteractivePanel( + overrides: Partial> = {} +): Promise { + await act(async () => { + root?.render( + + + + ) + }) + await act(async () => {}) +} + +function findButton(label: string): HTMLButtonElement { + const button = Array.from((container ?? document.body).querySelectorAll('button')).find( + (candidate) => candidate.textContent?.trim() === label + ) + expect(button).toBeDefined() + return button as HTMLButtonElement +} + +async function clickButton(label: string): Promise { + await act(async () => { + findButton(label).dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await act(async () => {}) +} + describe('AgentSkillSetupPanel', () => { + beforeEach(() => { + mocks.clipboardWrite.mockReset() + mocks.clipboardWrite.mockResolvedValue(undefined) + mocks.terminalProps.length = 0 + mocks.toastError.mockReset() + mocks.toastSuccess.mockReset() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + cli: { + getInstallStatus: vi.fn() + }, + ui: { + writeClipboardText: mocks.clipboardWrite + } + } + }) + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + Reflect.deleteProperty(window, 'api') + }) + it('keeps the install action visible after the skill is detected', () => { const html = renderPanel({ installed: true }) @@ -65,6 +177,25 @@ describe('AgentSkillSetupPanel', () => { expect(buttonLabels(html)).not.toContain('Install CLI & Skill') }) + it('keeps the installed action label when CLI prerequisites are missing', async () => { + await renderInteractivePanel({ + installed: true, + installedCommand: UPDATE_COMMAND, + installLabel: 'Install CLI & Skill', + preInstallNotice: 'Install the Orca CLI before running agent skill setup.', + getPrerequisiteStatus: vi.fn( + async () => + ({ + state: 'not_installed' + }) as Awaited> + ), + isPrerequisiteAvailable: () => false + }) + + expect(findButton('Update').disabled).toBe(false) + expect(container?.textContent).not.toContain('Install CLI & Skill') + }) + it('can hide install after the skill is detected', () => { const html = renderPanel({ installed: true, showInstallWhenInstalled: false }) @@ -85,4 +216,62 @@ describe('AgentSkillSetupPanel', () => { expect(buttonMarkupByLabel(html, 'Install')).toContain('disabled=""') }) + + it('opens not-installed setup with the install command for preview, copy, and terminal', async () => { + await renderInteractivePanel({ installedCommand: UPDATE_COMMAND }) + + await clickButton('Install') + + expect(container?.textContent).toContain(INSTALL_COMMAND) + expect(mocks.terminalProps.at(-1)).toMatchObject({ + command: INSTALL_COMMAND, + description: 'Press Enter to run the command.' + }) + + await act(async () => { + container + ?.querySelector('button[aria-label="Copy command"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.clipboardWrite).toHaveBeenCalledWith(INSTALL_COMMAND) + expect(mocks.toastSuccess).toHaveBeenCalledWith('Copied command.') + }) + + it('opens installed setup with the installed command for preview, copy, and terminal', async () => { + await renderInteractivePanel({ installed: true, installedCommand: UPDATE_COMMAND }) + + await clickButton('Update') + + expect(container?.textContent).toContain(UPDATE_COMMAND) + expect(mocks.terminalProps.at(-1)).toMatchObject({ command: UPDATE_COMMAND }) + + await act(async () => { + container + ?.querySelector('button[aria-label="Copy command"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.clipboardWrite).toHaveBeenCalledWith(UPDATE_COMMAND) + }) + + it('keeps an open terminal on the command captured when it opened', async () => { + await renderInteractivePanel({ installed: false, installedCommand: UPDATE_COMMAND }) + await clickButton('Install') + + await rerenderInteractivePanel({ installed: true, installedCommand: UPDATE_COMMAND }) + + expect(container?.textContent).toContain(INSTALL_COMMAND) + expect(container?.textContent).not.toContain(UPDATE_COMMAND) + expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND }) + }) + + it('falls back to the install command for installed callers without installedCommand', async () => { + await renderInteractivePanel({ installed: true }) + + await clickButton('Update') + + expect(container?.textContent).toContain(INSTALL_COMMAND) + expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND }) + }) }) diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index b4d1c2ece35..1453eaf02e4 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -18,6 +18,7 @@ type AgentSkillSetupPanelProps = { title: string description: ReactNode command: string + installedCommand?: string terminalTitle: string terminalAriaLabel: string terminalWorktreeId: string @@ -51,6 +52,7 @@ export function AgentSkillSetupPanel({ title, description, command, + installedCommand, terminalTitle, terminalAriaLabel, terminalWorktreeId, @@ -78,6 +80,7 @@ export function AgentSkillSetupPanel({ onRecheck }: AgentSkillSetupPanelProps): React.JSX.Element { const [terminalOpen, setTerminalOpen] = useState(false) + const [terminalCommand, setTerminalCommand] = useState(null) const [preInstallNoticeVisible, setPreInstallNoticeVisible] = useState( Boolean(preInstallNotice && !installed) ) @@ -86,7 +89,10 @@ export function AgentSkillSetupPanel({ () => (getPrerequisiteStatus ?? window.api.cli.getInstallStatus)(), [getPrerequisiteStatus] ) - const actionLabel = installed && preInstallNoticeVisible ? installLabel : installedInstallLabel + const activeCommand = installed ? (installedCommand ?? command) : command + // Why: the inline terminal auto-inserts when its command changes, so keep an + // already-open terminal pinned to the command selected by the user's click. + const openTerminalCommand = terminalCommand ?? activeCommand useEffect(() => { if (!preInstallNotice) { @@ -132,22 +138,19 @@ export function AgentSkillSetupPanel({ } } - const copyInstallCommand = async (): Promise => { + const copyActiveCommand = async (): Promise => { try { - await window.api.ui.writeClipboardText(command) + await window.api.ui.writeClipboardText(openTerminalCommand) toast.success( - translate( - 'auto.components.settings.AgentSkillSetupPanel.378ad26865', - 'Copied install command.' - ) + translate('auto.components.settings.AgentSkillSetupPanel.copiedCommand', 'Copied command.') ) } catch (error) { toast.error( error instanceof Error ? error.message : translate( - 'auto.components.settings.AgentSkillSetupPanel.a31e2aa302', - 'Failed to copy install command.' + 'auto.components.settings.AgentSkillSetupPanel.failedToCopyCommand', + 'Failed to copy command.' ) ) } @@ -161,12 +164,14 @@ export function AgentSkillSetupPanel({ variant="outline" size="sm" onClick={() => { + const nextCommand = activeCommand void (async () => { try { await onBeforeOpenTerminal?.() await refreshPreInstallNotice() } finally { if (mountedRef.current) { + setTerminalCommand(nextCommand) setTerminalOpen(true) } } @@ -175,7 +180,7 @@ export function AgentSkillSetupPanel({ disabled={terminalOpen || installDisabled} > - {installed ? actionLabel : installLabel} + {installed ? installedInstallLabel : installLabel} ) : null} {!installed || showRecheckWhenInstalled ? ( @@ -274,7 +279,7 @@ export function AgentSkillSetupPanel({ >
- {command} + {openTerminalCommand} @@ -284,10 +289,10 @@ export function AgentSkillSetupPanel({ size="icon-sm" className="shrink-0" aria-label={translate( - 'auto.components.settings.AgentSkillSetupPanel.817d3f9f18', - 'Copy install command' + 'auto.components.settings.AgentSkillSetupPanel.copyCommandAria', + 'Copy command' )} - onClick={() => void copyInstallCommand()} + onClick={() => void copyActiveCommand()} > @@ -302,11 +307,11 @@ export function AgentSkillSetupPanel({
{ @@ -275,6 +277,7 @@ export function BrowserUseSetup({ > ({ + props: null as null | Record +})) + +vi.mock('./AgentSkillSetupPanel', () => ({ + AgentSkillSetupPanel: (props: Record) => { + capturedPanel.props = props + return
+ } +})) + +describe('BrowserUseSkillStep', () => { + it('forwards a single-skill installed command even when setup installs a bundle', () => { + const bundleInstallCommand = + 'npx skills add https://github.com/stablyai/orca --skill orca-cli orchestration --global' + const updateCommand = 'npx skills update orca-cli --global' + + renderToStaticMarkup( + + ) + + expect(capturedPanel.props).toEqual( + expect.objectContaining({ + command: bundleInstallCommand, + installedCommand: updateCommand, + installed: true + }) + ) + }) +}) diff --git a/src/renderer/src/components/settings/BrowserUseSkillStep.tsx b/src/renderer/src/components/settings/BrowserUseSkillStep.tsx index 4d38db66d43..20788509441 100644 --- a/src/renderer/src/components/settings/BrowserUseSkillStep.tsx +++ b/src/renderer/src/components/settings/BrowserUseSkillStep.tsx @@ -5,6 +5,7 @@ import { translate } from '@/i18n/i18n' type Props = { command: string + installedCommand: string skillDetected: boolean skillLoading: boolean skillError: string | null @@ -18,6 +19,7 @@ type Props = { export function BrowserUseSkillStep({ command, + installedCommand, skillDetected, skillLoading, skillError, @@ -40,6 +42,7 @@ export function BrowserUseSkillStep({ "Enables agents to navigate and verify pages in Orca's browser." )} command={command} + installedCommand={installedCommand} terminalTitle="Browser Use setup" terminalAriaLabel="Browser Use skill install terminal" terminalWorktreeId="settings-browser-use-skill-terminal" diff --git a/src/renderer/src/components/settings/CliAgentSkillSetup.tsx b/src/renderer/src/components/settings/CliAgentSkillSetup.tsx index 3241cb6dab9..f01364d9154 100644 --- a/src/renderer/src/components/settings/CliAgentSkillSetup.tsx +++ b/src/renderer/src/components/settings/CliAgentSkillSetup.tsx @@ -1,10 +1,10 @@ import { useCallback, useMemo } from 'react' import type { CliInstallStatus } from '../../../../shared/cli-install-types' -import type { SkillDiscoveryTarget } from '../../../../shared/skills' import type { GlobalSettings } from '../../../../shared/types' import { ORCA_CLI_SKILL_INSTALL_COMMAND, - ORCA_CLI_SKILL_NAME + ORCA_CLI_SKILL_NAME, + ORCA_CLI_SKILL_UPDATE_COMMAND } from '@/lib/agent-feature-install-commands' import { AGENT_SKILL_CLI_PREREQUISITE_NOTICE, @@ -17,10 +17,11 @@ import { } from '@/hooks/useInstalledAgentSkills' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { - buildSkillInstallCommandForRuntime, + buildSkillCommandForRuntime, ensureWslCliAvailableForAgentSkillTerminal, getAgentSkillTerminalShellOverride, getSelectedAgentRuntime, + getSkillDiscoveryTargetForRuntime, getWslCliDistroRequest } from './CliSkillRuntimeSetup' import { Label } from '../ui/label' @@ -48,12 +49,9 @@ export function CliAgentSkillSetup({ getSelectedAgentRuntime(settings, wslSupportedPlatform, wslAvailable, wslCapabilitiesLoading), [settings, wslAvailable, wslCapabilitiesLoading, wslSupportedPlatform] ) - const cliSkillDiscoveryTarget = useMemo( - () => - agentRuntime.runtime === 'wsl' - ? { runtime: 'wsl', wslDistro: agentRuntime.wslDistro } - : undefined, - [agentRuntime.runtime, agentRuntime.wslDistro] + const cliSkillDiscoveryTarget = useMemo( + () => getSkillDiscoveryTargetForRuntime(agentRuntime), + [agentRuntime] ) const { installed: cliSkillDetected, @@ -64,10 +62,14 @@ export function CliAgentSkillSetup({ discoveryTarget: cliSkillDiscoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) - const cliSkillInstallCommand = buildSkillInstallCommandForRuntime( + const cliSkillInstallCommand = buildSkillCommandForRuntime( ORCA_CLI_SKILL_INSTALL_COMMAND, agentRuntime ) + const cliSkillUpdateCommand = buildSkillCommandForRuntime( + ORCA_CLI_SKILL_UPDATE_COMMAND, + agentRuntime + ) const cliSkillTerminalShellOverride = getAgentSkillTerminalShellOverride( currentPlatform, settings, @@ -102,6 +104,7 @@ export function CliAgentSkillSetup({ 'Enables agents to use Orca workspace, terminal, and progress commands.' )} command={cliSkillInstallCommand} + installedCommand={cliSkillUpdateCommand} terminalTitle={translate( 'auto.components.settings.CliSection.cliSkillTerminalTitle', 'CLI skill setup' diff --git a/src/renderer/src/components/settings/CliSection.test.tsx b/src/renderer/src/components/settings/CliSection.test.tsx index c8d5e28e90a..db46bd69b79 100644 --- a/src/renderer/src/components/settings/CliSection.test.tsx +++ b/src/renderer/src/components/settings/CliSection.test.tsx @@ -5,23 +5,30 @@ import { CliSection } from './CliSection' const capturedPanel = vi.hoisted(() => ({ props: null as null | { + command: string + installedCommand: string getPrerequisiteStatus: () => Promise onBeforeOpenTerminal: () => Promise - } + }, + useInstalledAgentSkill: vi.fn() })) vi.mock('@/hooks/useInstalledAgentSkills', () => ({ GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['global'], - useInstalledAgentSkill: () => ({ - installed: false, - loading: false, - error: null, - refresh: vi.fn() - }) + useInstalledAgentSkill: capturedPanel.useInstalledAgentSkill })) +capturedPanel.useInstalledAgentSkill.mockReturnValue({ + installed: false, + loading: false, + error: null, + refresh: vi.fn() +}) + vi.mock('./AgentSkillSetupPanel', () => ({ AgentSkillSetupPanel: function AgentSkillSetupPanel(props: { + command: string + installedCommand: string getPrerequisiteStatus: () => Promise onBeforeOpenTerminal: () => Promise }) { @@ -75,6 +82,17 @@ describe('CliSection project runtime defaults', () => { await capturedPanel.props?.getPrerequisiteStatus() await capturedPanel.props?.onBeforeOpenTerminal() + expect(capturedPanel.useInstalledAgentSkill).toHaveBeenCalledWith( + 'orca-cli', + expect.objectContaining({ + discoveryTarget: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + sourceKinds: ['global'] + }) + ) + expect(capturedPanel.props?.command).toContain("wsl.exe -d 'Ubuntu' -- sh -c") + expect(capturedPanel.props?.command).toContain('npx skills add') + expect(capturedPanel.props?.installedCommand).toContain("wsl.exe -d 'Ubuntu' -- sh -c") + expect(capturedPanel.props?.installedCommand).toContain('npx skills update orca-cli --global') expect(getWslInstallStatus).toHaveBeenCalledWith({ distro: 'Ubuntu' }) expect(getWslInstallStatus).toHaveBeenCalledTimes(2) }) diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index ad0336eda8a..20380332009 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { FolderOpen, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import type { CliInstallStatus } from '../../../../shared/cli-install-types' -import type { SkillDiscoveryTarget } from '../../../../shared/skills' import type { GlobalSettings } from '../../../../shared/types' import { ORCA_CLI_SKILL_INSTALL_COMMAND, - ORCA_CLI_SKILL_NAME + ORCA_CLI_SKILL_NAME, + ORCA_CLI_SKILL_UPDATE_COMMAND } from '@/lib/agent-feature-install-commands' import { AGENT_SKILL_CLI_PREREQUISITE_NOTICE, @@ -24,10 +24,11 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/ import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { CliRegistrationDialog } from './CliRegistrationDialog' import { - buildSkillInstallCommandForRuntime, + buildSkillCommandForRuntime, ensureWslCliAvailableForAgentSkillTerminal, getAgentSkillTerminalShellOverride, getSelectedAgentRuntime, + getSkillDiscoveryTargetForRuntime, getWslCliDistroRequest } from './CliSkillRuntimeSetup' import { WslCliRegistration } from './WslCliRegistration' @@ -85,9 +86,9 @@ export function CliSection({ getSelectedAgentRuntime(settings, wslSupportedPlatform, wslAvailable, wslCapabilitiesLoading), [settings, wslAvailable, wslCapabilitiesLoading, wslSupportedPlatform] ) - const cliSkillDiscoveryTarget = useMemo( - () => (agentRuntime.runtime === 'wsl' ? { runtime: 'wsl' } : undefined), - [agentRuntime.runtime] + const cliSkillDiscoveryTarget = useMemo( + () => getSkillDiscoveryTargetForRuntime(agentRuntime), + [agentRuntime] ) const { installed: cliSkillDetected, @@ -98,10 +99,14 @@ export function CliSection({ discoveryTarget: cliSkillDiscoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) - const cliSkillInstallCommand = buildSkillInstallCommandForRuntime( + const cliSkillInstallCommand = buildSkillCommandForRuntime( ORCA_CLI_SKILL_INSTALL_COMMAND, agentRuntime ) + const cliSkillUpdateCommand = buildSkillCommandForRuntime( + ORCA_CLI_SKILL_UPDATE_COMMAND, + agentRuntime + ) const cliSkillTerminalShellOverride = getAgentSkillTerminalShellOverride( currentPlatform, settings, @@ -366,6 +371,7 @@ export function CliSection({ 'Enables agents to use Orca workspace, terminal, and progress commands.' )} command={cliSkillInstallCommand} + installedCommand={cliSkillUpdateCommand} terminalTitle="CLI skill setup" terminalAriaLabel="CLI skill install terminal" terminalWorktreeId={`settings-cli-skill-terminal-${agentRuntime.runtime}`} diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx index a0971f5538f..02c1fa65981 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import { + buildSkillCommandForRuntime, buildSkillInstallCommandForRuntime, getSelectedAgentRuntime, getSkillDiscoveryTargetForRuntime @@ -19,6 +20,18 @@ describe('CliSkillRuntimeSetup runtime helpers', () => { expect(command).toContain('npx skills add orchestration --global') }) + it('wraps WSL skill updates with the same selected distro login shell', () => { + const command = buildSkillCommandForRuntime('npx skills update orchestration --global', { + runtime: 'wsl', + wslDistro: 'Fedora Remix', + label: 'WSL Fedora Remix' + }) + + expect(command).toContain("wsl.exe -d 'Fedora Remix' -- sh -c") + expect(command).toContain('getent passwd') + expect(command).toContain('npx skills update orchestration --global') + }) + it('preserves the selected WSL distro for skill discovery', () => { expect( getSkillDiscoveryTargetForRuntime({ diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index 6f3b44ff7ee..686115955a7 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -62,10 +62,7 @@ export function getWslCliDistroRequest( : undefined } -export function buildSkillInstallCommandForRuntime( - command: string, - runtime: LocalAgentRuntime -): string { +export function buildSkillCommandForRuntime(command: string, runtime: LocalAgentRuntime): string { if (runtime.runtime !== 'wsl') { return command } @@ -76,6 +73,13 @@ export function buildSkillInstallCommandForRuntime( return `wsl.exe${distroArg} -- sh -c ${quotePowerShellSingle(wslCommand)}` } +export function buildSkillInstallCommandForRuntime( + command: string, + runtime: LocalAgentRuntime +): string { + return buildSkillCommandForRuntime(command, runtime) +} + export function getSkillDiscoveryTargetForRuntime( runtime: LocalAgentRuntime ): { runtime: 'wsl'; wslDistro?: string | null } | undefined { diff --git a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx index 5d11337b3b5..46107b7354b 100644 --- a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx @@ -1,7 +1,8 @@ import { MonitorCog } from 'lucide-react' import { COMPUTER_USE_SKILL_INSTALL_COMMAND, - COMPUTER_USE_SKILL_NAME + COMPUTER_USE_SKILL_NAME, + COMPUTER_USE_SKILL_UPDATE_COMMAND } from '@/lib/agent-feature-install-commands' import { AGENT_SKILL_CLI_PREREQUISITE_NOTICE, @@ -15,7 +16,7 @@ import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRunti import { useAppStore } from '@/store' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { - buildSkillInstallCommandForRuntime, + buildSkillCommandForRuntime, ensureWslCliAvailableForAgentSkillTerminal, getWslCliDistroRequest } from './CliSkillRuntimeSetup' @@ -25,11 +26,18 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element { const activeSkillRuntime = useActiveProjectSkillRuntime() const installCommand = activeSkillRuntime.agentRuntime && !activeSkillRuntime.installDisabledReason - ? buildSkillInstallCommandForRuntime( + ? buildSkillCommandForRuntime( COMPUTER_USE_SKILL_INSTALL_COMMAND, activeSkillRuntime.agentRuntime ) : COMPUTER_USE_SKILL_INSTALL_COMMAND + const updateCommand = + activeSkillRuntime.agentRuntime && !activeSkillRuntime.installDisabledReason + ? buildSkillCommandForRuntime( + COMPUTER_USE_SKILL_UPDATE_COMMAND, + activeSkillRuntime.agentRuntime + ) + : COMPUTER_USE_SKILL_UPDATE_COMMAND const { installed: computerUseSkillDetected, loading: computerUseSkillLoading, @@ -48,6 +56,7 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element { 'Enables agents to inspect and operate local desktop apps.' )} command={installCommand} + installedCommand={updateCommand} terminalTitle="Computer Use setup" terminalAriaLabel="Computer Use skill install terminal" terminalWorktreeId="settings-computer-use-skill-terminal" diff --git a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx index a52fe6c2247..17622fa18c0 100644 --- a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx +++ b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx @@ -1,5 +1,8 @@ import { Import, Loader2 } from 'lucide-react' -import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands' +import { + ORCA_CLI_SKILL_INSTALL_COMMAND, + ORCA_CLI_SKILL_UPDATE_COMMAND +} from '@/lib/agent-feature-install-commands' import { AGENT_SKILL_CLI_PREREQUISITE_NOTICE, ensureOrcaCliAvailableForAgentSkillTerminal @@ -128,6 +131,7 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element { 'Enables agents to use Orca CLI commands, including mobile emulator control.' )} command={ORCA_CLI_SKILL_INSTALL_COMMAND} + installedCommand={ORCA_CLI_SKILL_UPDATE_COMMAND} terminalTitle="Orca CLI skill setup" terminalAriaLabel="Orca CLI skill install terminal" terminalWorktreeId="settings-mobile-emulator-orca-cli-skill-terminal" diff --git a/src/renderer/src/components/settings/OrchestrationPane.test.tsx b/src/renderer/src/components/settings/OrchestrationPane.test.tsx index a158b657660..ed0f2f074bb 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.test.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.test.tsx @@ -1,12 +1,57 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { getOrchestrationUsageExamples } from '@/lib/orchestration-usage-examples' import { OrchestrationPane } from './OrchestrationPane' +const INSTALL_COMMAND = + 'npx skills add https://github.com/stablyai/orca --skill orchestration --global' +const UPDATE_COMMAND = 'npx skills update orchestration --global' + +const mocks = vi.hoisted(() => ({ + dialogProps: [] as Record[], + panelProps: [] as Record[], + skillInstalled: true +})) + +vi.mock('./AgentSkillSetupPanel', () => ({ + AgentSkillSetupPanel: ( + props: Record & { actionHint?: ReactNode; footer?: ReactNode } + ) => { + mocks.panelProps.push(props) + return ( +
+

{String(props.title)}

+ {props.installed ? 'Installed' : 'Not installed'} + {String(props.command)} + {String(props.installedCommand)} + + + {props.actionHint} + {props.footer} +
+ ) + } +})) + +vi.mock('./OrchestrationSkillPromptDialog', () => ({ + OrchestrationSkillPromptDialog: (props: Record) => { + mocks.dialogProps.push(props) + return props.open ? ( +
+ {String(props.command)} +
+ ) : null + } +})) + vi.mock('@/hooks/useInstalledAgentSkills', () => ({ GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['home'], useInstalledAgentSkill: () => ({ - installed: true, + installed: mocks.skillInstalled, loading: false, error: null, skills: [ @@ -38,14 +83,42 @@ vi.mock('@/hooks/useDetectedAgents', () => ({ }) })) +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderPane(): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) + return container +} + describe('OrchestrationPane', () => { + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + mocks.dialogProps.length = 0 + mocks.panelProps.length = 0 + mocks.skillInstalled = true + }) + it('keeps skill setup visible after install and shows agent coverage plus examples', () => { const markup = renderToStaticMarkup() expect(markup).toContain('Orchestration skill') expect(markup).toContain('Installed') expect(markup).toContain('Agent coverage') - expect(markup).toContain('Copy install command') + expect(markup).not.toContain('Prefer your own terminal?') + expect(markup).not.toContain('Copy update command') expect(markup).toContain('detected agents') expect(markup).toContain('Gemini') expect(markup).toContain('Ready') @@ -59,4 +132,52 @@ describe('OrchestrationPane', () => { expect(markup).toMatch(/]*>[\s\S]*?Update[\s\S]*?<\/button>/) expect(markup).toContain('Re-check') }) + + it('passes update commands to the main panel without an installed manual-copy path', async () => { + const rendered = await renderPane() + + expect(mocks.panelProps.at(-1)).toEqual( + expect.objectContaining({ + command: INSTALL_COMMAND, + installedCommand: UPDATE_COMMAND + }) + ) + + expect(rendered.textContent).not.toContain('Prefer your own terminal?') + expect(rendered.textContent).not.toContain('Copy update command') + expect(rendered.textContent).not.toContain('Copy install command') + expect(mocks.dialogProps).not.toContainEqual(expect.objectContaining({ mode: 'update' })) + expect(mocks.dialogProps).not.toContainEqual( + expect.objectContaining({ + command: UPDATE_COMMAND, + open: true + }) + ) + }) + + it('keeps first-time manual copy on the install command', async () => { + mocks.skillInstalled = false + const rendered = await renderPane() + + expect(rendered.textContent).toContain('Prefer your own terminal?') + expect(rendered.textContent).toContain('Copy install command') + expect(rendered.textContent).not.toContain('Copy update command') + + const copyButton = Array.from(rendered.querySelectorAll('button')).find( + (button) => button.textContent === 'Copy install command' + ) + expect(copyButton).toBeDefined() + + await act(async () => { + copyButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.dialogProps.at(-1)).toEqual( + expect.objectContaining({ + command: INSTALL_COMMAND, + open: true + }) + ) + expect(rendered.textContent).toContain(INSTALL_COMMAND) + }) }) diff --git a/src/renderer/src/components/settings/OrchestrationPane.tsx b/src/renderer/src/components/settings/OrchestrationPane.tsx index 0fd593d3edf..460ccf3f21d 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.tsx @@ -5,7 +5,10 @@ import { AGENT_SKILL_CLI_PREREQUISITE_NOTICE, ensureOrcaCliAvailableForAgentSkillTerminal } from '@/lib/agent-skill-cli-prerequisite' -import { ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/orchestration-install-command' +import { + ORCHESTRATION_SKILL_INSTALL_COMMAND, + ORCHESTRATION_SKILL_UPDATE_COMMAND +} from '@/lib/orchestration-install-command' import { getOrchestrationUsageExamples } from '@/lib/orchestration-usage-examples' import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, @@ -18,7 +21,7 @@ import { useAppStore } from '../../store' import { getOrchestrationPaneSearchEntries } from './orchestration-search' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { - buildSkillInstallCommandForRuntime, + buildSkillCommandForRuntime, ensureWslCliAvailableForAgentSkillTerminal, getWslCliDistroRequest } from './CliSkillRuntimeSetup' @@ -43,11 +46,18 @@ export function OrchestrationPane(): React.JSX.Element { const activeSkillRuntime = useActiveProjectSkillRuntime() const orchestrationInstallCommand = activeSkillRuntime.agentRuntime && !activeSkillRuntime.installDisabledReason - ? buildSkillInstallCommandForRuntime( + ? buildSkillCommandForRuntime( ORCHESTRATION_SKILL_INSTALL_COMMAND, activeSkillRuntime.agentRuntime ) : ORCHESTRATION_SKILL_INSTALL_COMMAND + const orchestrationUpdateCommand = + activeSkillRuntime.agentRuntime && !activeSkillRuntime.installDisabledReason + ? buildSkillCommandForRuntime( + ORCHESTRATION_SKILL_UPDATE_COMMAND, + activeSkillRuntime.agentRuntime + ) + : ORCHESTRATION_SKILL_UPDATE_COMMAND const { installed: orchestrationSkillDetected, @@ -87,6 +97,7 @@ export function OrchestrationPane(): React.JSX.Element { 'Enables agents to hand off context and coordinate work through Orca.' )} command={orchestrationInstallCommand} + installedCommand={orchestrationUpdateCommand} terminalTitle="Orchestration setup" terminalAriaLabel="Orchestration skill install terminal" terminalWorktreeId="settings-orchestration-skill-terminal" @@ -111,7 +122,8 @@ export function OrchestrationPane(): React.JSX.Element { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} actionHint={ - activeSkillRuntime.installDisabledReason ? null : ( + // Installed updates stay on the primary panel so there is only one update path. + activeSkillRuntime.installDisabledReason || orchestrationSkillDetected ? null : (

{translate( 'auto.components.settings.OrchestrationPane.832f1f3ee6', @@ -120,7 +132,9 @@ export function OrchestrationPane(): React.JSX.Element { - {translate( - 'auto.components.right.sidebar.PortsPanel.b22b128b2a', - 'Open in Browser' - )} + {getPortOpenBrowserTooltipLabel(openBrowserLabel)} @@ -672,9 +680,12 @@ function LocalPortRow({ {`:${port.port}`} - + handleOpenBrowser()} + > - {translate('auto.components.right.sidebar.PortsPanel.b22b128b2a', 'Open in Browser')} + {openBrowserLabel} @@ -850,9 +861,15 @@ function SshPortsPanel(): React.JSX.Element { }, []) const handleOpenForwardInBrowser = useCallback( - (entry: PortForwardEntry) => { + (entry: PortForwardEntry, event?: React.MouseEvent) => { const url = browserUrlForPortForwardEntry(entry) - if (!shouldOpenWorkspacePortInOrcaBrowser(settings)) { + if ( + !resolvePortOpenInOrcaBrowser({ + settings, + event, + isMac: navigator.userAgent.includes('Mac') + }) + ) { void window.api.shell.openUrl(url) return } @@ -935,7 +952,7 @@ function SshPortsPanel(): React.JSX.Element { key={entry.id} entry={entry} onEdit={() => handleEdit(entry)} - onOpenInBrowser={() => handleOpenForwardInBrowser(entry)} + onOpenInBrowser={(event) => handleOpenForwardInBrowser(entry, event)} /> ))}

@@ -1015,7 +1032,7 @@ function ForwardedPortRow({ }: { entry: PortForwardEntry onEdit: () => void - onOpenInBrowser: () => void + onOpenInBrowser: (event?: React.MouseEvent) => void }): React.JSX.Element { const [removing, setRemoving] = useState(false) const mountedRef = useMountedRef() @@ -1037,9 +1054,12 @@ function ForwardedPortRow({ void window.api.ui.writeClipboardText(forwardedAddress) }, [forwardedAddress]) - const handleOpenBrowser = useCallback(() => { - onOpenInBrowser() - }, [onOpenInBrowser]) + const handleOpenBrowser = useCallback( + (event?: React.MouseEvent) => { + onOpenInBrowser(event) + }, + [onOpenInBrowser] + ) const handleCopyButtonClick = useCallback( (event: React.MouseEvent) => { @@ -1053,7 +1073,9 @@ function ForwardedPortRow({ const handleOpenBrowserButtonClick = useCallback( (event: React.MouseEvent) => { - handleOpenBrowser() + // Why: keyboard activations have detail=0; only pointer clicks carry + // the modifier intent for the system-browser escape hatch. + handleOpenBrowser(event.detail > 0 ? event : undefined) if (event.detail > 0) { event.currentTarget.blur() } @@ -1082,6 +1104,21 @@ function ForwardedPortRow({ ) const advertisedBrowserUrl = advertisedBrowserUrlForForwardedRow(entry) + const openBrowserLabel = translate( + 'auto.components.right.sidebar.PortsPanel.b22b128b2a', + 'Open in Browser' + ) + const openBrowserTitle = getPortOpenBrowserTooltipLabel( + advertisedBrowserUrl + ? translate( + 'auto.components.right.sidebar.PortsPanel.75aeea592f', + 'Open {{value0}} in Browser', + { + value0: advertisedBrowserUrl + } + ) + : openBrowserLabel + ) return (
@@ -1112,15 +1149,7 @@ function ForwardedPortRow({ type="button" className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground" onClick={handleOpenBrowserButtonClick} - title={ - advertisedBrowserUrl - ? translate( - 'auto.components.right.sidebar.PortsPanel.75aeea592f', - 'Open {{value0}} in Browser', - { value0: advertisedBrowserUrl } - ) - : translate('auto.components.right.sidebar.PortsPanel.b22b128b2a', 'Open in Browser') - } + title={openBrowserTitle} > diff --git a/src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx index 774b0dc30d4..0a8bb4d248e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx @@ -47,6 +47,7 @@ describe('WorktreeCardPortsDetails', () => { expect(markup).toContain('dev.preview.localhost:58941') expect(markup).toContain('aria-label="Copy dev.preview.localhost:58941"') + expect(markup).toContain('Open in Browser. Shift+Ctrl+click for system browser') expect(markup).toContain( '
- {label} + {tooltipLabel} ) @@ -115,17 +118,28 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process') const address = addressForPort(port) const canStop = canStopWorkspacePort(port) + const openBrowserLabel = translate( + 'auto.components.sidebar.WorktreeCardPorts.33bc7d7495', + 'Open in Browser' + ) const handleOpen = useCallback( (event: React.MouseEvent) => { event.stopPropagation() recordFeatureInteraction('ports') + const openInOrcaBrowser = resolvePortOpenInOrcaBrowser({ + settings, + // Why: keyboard activations have detail=0; only pointer clicks carry + // the modifier intent for the system-browser escape hatch. + event: event.detail > 0 ? event : null, + isMac: navigator.userAgent.includes('Mac') + }) void openWorkspacePortInBrowser({ port, runtimeTarget, createBrowserTab, setRemoteBrowserPageHandle, - openInOrcaBrowser: shouldOpenWorkspacePortInOrcaBrowser(settings) + openInOrcaBrowser }).then((result) => { if (!result.ok) { toast.error( @@ -244,10 +258,8 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element {
diff --git a/src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx b/src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx new file mode 100644 index 00000000000..47c86d89c57 --- /dev/null +++ b/src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspacePort } from '../../../../shared/workspace-ports' + +const { + activateAndRevealWorktreeMock, + createBrowserTabMock, + openUrlMock, + recordFeatureInteractionMock, + setRemoteBrowserPageHandleMock, + storeState +} = vi.hoisted(() => { + const state = { + settings: { openLinksInApp: true }, + createBrowserTab: vi.fn(), + setRemoteBrowserPageHandle: vi.fn(), + setWorkspacePortScan: vi.fn(), + setWorkspacePortScanForKey: vi.fn(), + setWorkspacePortScanRefreshing: vi.fn(), + recordFeatureInteraction: vi.fn(), + workspacePortScansByKey: {} + } + return { + activateAndRevealWorktreeMock: vi.fn(), + createBrowserTabMock: state.createBrowserTab, + openUrlMock: vi.fn(), + recordFeatureInteractionMock: state.recordFeatureInteraction, + setRemoteBrowserPageHandleMock: state.setRemoteBrowserPageHandle, + storeState: state + } +}) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector: (state: typeof storeState) => unknown) => selector(storeState), + { getState: () => storeState } + ) + return { useAppStore } +}) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: activateAndRevealWorktreeMock +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => null +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }), + callRuntimeRpc: vi.fn(), + RuntimeRpcCallError: class RuntimeRpcCallError extends Error { + code?: string + } +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + success: vi.fn() + } +})) + +import { PortRow } from './ports-status-popover-rows' + +const externalPort: WorkspacePort = { + id: '127.0.0.1:63468:1234', + bindHost: '127.0.0.1', + connectHost: '127.0.0.1', + port: 63468, + pid: 1234, + processName: 'node', + protocol: 'http', + kind: 'external' +} + +describe('status bar port row open routing', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + Object.defineProperty(window.navigator, 'userAgent', { + value: 'Mozilla/5.0 (X11; Linux x86_64)', + configurable: true + }) + ;(window as unknown as { api: unknown }).api = { + shell: { + openUrl: openUrlMock + }, + ui: { + writeClipboardText: vi.fn() + } + } + openUrlMock.mockResolvedValue(undefined) + createBrowserTabMock.mockReset() + openUrlMock.mockClear() + recordFeatureInteractionMock.mockClear() + setRemoteBrowserPageHandleMock.mockClear() + activateAndRevealWorktreeMock.mockClear() + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + function renderPortRow(): HTMLButtonElement { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root.render() + }) + const openButton = container.querySelector( + 'button[aria-label="Open in Browser"]' + ) + if (!openButton) { + throw new Error('expected Open in Browser button') + } + expect(container.textContent).toContain('Open in Browser. Shift+Ctrl+click for system browser') + return openButton + } + + it('keeps the open button enabled and forwards Shift+Ctrl-click to system-browser routing', async () => { + const openButton = renderPortRow() + + expect(openButton.disabled).toBe(false) + + await act(async () => { + openButton.dispatchEvent( + new window.MouseEvent('click', { + bubbles: true, + cancelable: true, + ctrlKey: true, + detail: 1, + shiftKey: true + }) + ) + await Promise.resolve() + }) + + expect(recordFeatureInteractionMock).toHaveBeenCalledWith('ports') + expect(openUrlMock).toHaveBeenCalledWith('http://127.0.0.1:63468') + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(activateAndRevealWorktreeMock).not.toHaveBeenCalled() + }) + + it('keeps no-pointer activations on the saved link-routing setting', async () => { + const openButton = renderPortRow() + + expect(openButton.disabled).toBe(false) + + await act(async () => { + openButton.dispatchEvent( + new window.MouseEvent('click', { + bubbles: true, + cancelable: true, + ctrlKey: true, + shiftKey: true + }) + ) + await Promise.resolve() + }) + + expect(recordFeatureInteractionMock).toHaveBeenCalledWith('ports') + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx b/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx index b0a4103379b..b88c41eaf59 100644 --- a/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx +++ b/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx @@ -6,11 +6,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { addressForPort, canStopWorkspacePort, + getPortOpenBrowserTooltipLabel, goToWorkspacePortOwner, killWorkspacePortForTarget, openWorkspacePortInBrowser, refreshWorkspacePortScanAfterStop, - shouldOpenWorkspacePortInOrcaBrowser + resolvePortOpenInOrcaBrowser } from '@/lib/workspace-port-actions' import type { WorkspacePortGroup } from '@/lib/workspace-port-groups' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' @@ -21,11 +22,13 @@ import { translate } from '@/i18n/i18n' function PortAction({ label, + tooltipLabel = label, onClick, disabled, children }: { label: string + tooltipLabel?: string onClick: (event: React.MouseEvent) => void disabled?: boolean children: React.ReactNode @@ -57,7 +60,7 @@ function PortAction({ {disabled ? {button} : button} - {label} + {tooltipLabel} ) @@ -90,14 +93,23 @@ export function PortRow({ [runtimeEnvironmentId, settings] ) const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process') - const openInOrcaBrowser = shouldOpenWorkspacePortInOrcaBrowser(settings) - const canOpen = !openInOrcaBrowser || port.kind === 'workspace' || Boolean(activeWorktreeId) const canStop = canStopWorkspacePort(port) + const openBrowserLabel = translate( + 'auto.components.status.bar.ports.status.popover.rows.085f4f0334', + 'Open in Browser' + ) const handleOpen = useCallback( (event: React.MouseEvent) => { event.stopPropagation() recordFeatureInteraction('ports') + const openInOrcaBrowser = resolvePortOpenInOrcaBrowser({ + settings, + // Why: keyboard activations have detail=0; only pointer clicks carry + // the modifier intent for the system-browser escape hatch. + event: event.detail > 0 ? event : null, + isMac: navigator.userAgent.includes('Mac') + }) void openWorkspacePortInBrowser({ port, activeWorktreeId, @@ -120,10 +132,10 @@ export function PortRow({ [ activeWorktreeId, createBrowserTab, - openInOrcaBrowser, port, recordFeatureInteraction, runtimeTarget, + settings, setRemoteBrowserPageHandle ] ) @@ -219,12 +231,9 @@ export function PortRow({
diff --git a/src/renderer/src/lib/workspace-port-actions.ts b/src/renderer/src/lib/workspace-port-actions.ts index 8e40333e7b8..2a3a9366743 100644 --- a/src/renderer/src/lib/workspace-port-actions.ts +++ b/src/renderer/src/lib/workspace-port-actions.ts @@ -46,6 +46,37 @@ export function shouldOpenWorkspacePortInOrcaBrowser( return settings?.openLinksInApp === true } +function isMacShortcutPlatform(): boolean { + return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') +} + +export function getPortSystemBrowserHint(isMac: boolean = isMacShortcutPlatform()): string { + return isMac ? '⇧⌘+click for system browser' : 'Shift+Ctrl+click for system browser' +} + +export function getPortOpenBrowserTooltipLabel(openLabel: string, isMac?: boolean): string { + return `${openLabel}. ${getPortSystemBrowserHint(isMac)}` +} + +type PortOpenClickEvent = Pick + +export function resolvePortOpenInOrcaBrowser({ + settings, + event, + isMac +}: { + settings: { openLinksInApp?: boolean } | null | undefined + event?: PortOpenClickEvent | null + isMac: boolean +}): boolean { + // Why: Shift+Cmd/Ctrl is the external-browser escape hatch; no pointer + // event means context-menu and keyboard opens should keep the saved setting. + if (event?.shiftKey && (isMac ? event.metaKey : event.ctrlKey)) { + return false + } + return shouldOpenWorkspacePortInOrcaBrowser(settings) +} + export function workspacePortOwnerWorktreeId(port: WorkspacePort): string | null { return port.kind === 'workspace' ? port.owner.worktreeId : null } From e7af3676f2f762df4116c1eaabc184fd7a1b9a5f Mon Sep 17 00:00:00 2001 From: Janderson Fagner <135186779+janderson-fagner@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:30:44 -0300 Subject: [PATCH 012/163] feat(tray): Windows system tray with minimize-to-tray on close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Windows system tray support and a minimize-to-tray setting for close behavior. --- src/main/index.ts | 38 ++++ src/main/ipc/app.test.ts | 25 ++- src/main/ipc/app.ts | 4 + src/main/persistence.test.ts | 50 +++++ src/main/persistence.ts | 12 + src/main/tray/system-tray.test.ts | 142 ++++++++++++ src/main/tray/system-tray.ts | 58 +++++ src/main/window/createMainWindow.test.ts | 205 +++++++++++++++++- src/main/window/createMainWindow.ts | 67 +++++- .../components/settings/AppearancePane.tsx | 47 +++- .../components/settings/appearance-search.ts | 80 ++++++- .../settings/terminal-search.test.ts | 10 + .../hooks/useSettingsNavigationMetadata.ts | 13 +- src/renderer/src/i18n/locales/en.json | 12 + src/renderer/src/i18n/locales/es.json | 16 +- src/renderer/src/i18n/locales/ja.json | 16 +- src/renderer/src/i18n/locales/ko.json | 12 + src/renderer/src/i18n/locales/zh.json | 16 +- src/renderer/src/lib/web-client-location.ts | 9 + src/shared/constants.ts | 2 + src/shared/types.ts | 7 + 21 files changed, 802 insertions(+), 39 deletions(-) create mode 100644 src/main/tray/system-tray.test.ts create mode 100644 src/main/tray/system-tray.ts create mode 100644 src/renderer/src/lib/web-client-location.ts diff --git a/src/main/index.ts b/src/main/index.ts index 54fccd05dfb..891eb007161 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -78,6 +78,7 @@ import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' import { attachMainWindowServices } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' +import { createSystemTray, destroySystemTray } from './tray/system-tray' import { focusExistingMainWindow } from './window/focus-existing-window' import { CodexAccountService } from './codex-accounts/service' import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' @@ -554,6 +555,23 @@ function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): return runtimeHomePath } +// Why: tray "Open Orca" / left-click restores the window the close handler may +// have hidden to the tray; if the window was fully torn down, reopen it the +// same way macOS dock re-activation does (guarded against update relaunch). +function showMainWindowFromTray(): void { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + mainWindow.show() + mainWindow.focus() + return + } + if (!isQuittingForUpdate()) { + openMainWindow() + } +} + function openMainWindow(): BrowserWindow { logStartupMilestone('open-main-window-start') if (!store) { @@ -757,6 +775,23 @@ function openMainWindow(): BrowserWindow { stopAllSyntheticTitleSpinners() }) mainWindow = window + // Why: Windows-only system tray. createSystemTray is idempotent and a no-op + // off win32, so calling it on each window open keeps exactly one live icon. + createSystemTray({ + appIcon: store.getSettings().appIcon, + onOpen: showMainWindowFromTray, + onQuit: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + // Why: a real quit can still surface renderer save/discard prompts; the + // window must be visible if a hidden-to-tray session vetoes shutdown. + showMainWindowFromTray() + } + // Why: set the quit latch before app.quit() so the window 'close' handler + // proceeds to teardown instead of re-hiding the window to the tray. + isQuitting = true + app.quit() + } + }) window.on('show', resumeSyntheticTitleSpinnerTimer) window.on('restore', resumeSyntheticTitleSpinnerTimer) window.on('hide', stopSyntheticTitleSpinnerTimer) @@ -1663,6 +1698,9 @@ app.on('before-quit', () => { // async work and let Electron exit. let daemonDisconnectDone = false app.on('will-quit', (e) => { + // Why: before-quit can still be aborted by renderer beforeunload; wait until + // the committed quit path before removing the Windows notification icon. + destroySystemTray() // Why: stats.flush() must run before killAllPty() so it can read the // live agent state and emit synthetic agent_stop events for agents that // are still running. killAllPty() does not call runtime.onPtyExit(), diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 76cc5fb69bf..68c3403c843 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -1,12 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { handlers, appExitMock, appQuitMock, appRelaunchMock, execFileMock } = vi.hoisted(() => ({ - handlers: new Map unknown>(), - appExitMock: vi.fn(), - appQuitMock: vi.fn(), - appRelaunchMock: vi.fn(), - execFileMock: vi.fn() -})) +const { handlers, appExitMock, appQuitMock, appRelaunchMock, execFileMock, destroySystemTrayMock } = + vi.hoisted(() => ({ + handlers: new Map unknown>(), + appExitMock: vi.fn(), + appQuitMock: vi.fn(), + appRelaunchMock: vi.fn(), + execFileMock: vi.fn(), + destroySystemTrayMock: vi.fn() + })) vi.mock('node:child_process', () => ({ execFile: execFileMock @@ -37,6 +39,10 @@ vi.mock('@electron-toolkit/utils', () => ({ is: { dev: true } })) +vi.mock('../tray/system-tray', () => ({ + destroySystemTray: destroySystemTrayMock +})) + import { registerAppHandlers } from './app' describe('registerAppHandlers', () => { @@ -49,6 +55,7 @@ describe('registerAppHandlers', () => { appQuitMock.mockReset() appRelaunchMock.mockReset() execFileMock.mockReset() + destroySystemTrayMock.mockReset() Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) }) @@ -70,8 +77,12 @@ describe('registerAppHandlers', () => { await relaunchPromise await vi.advanceTimersByTimeAsync(150) + expect(destroySystemTrayMock).toHaveBeenCalledTimes(1) expect(appRelaunchMock).toHaveBeenCalledTimes(1) expect(appExitMock).toHaveBeenCalledWith(0) + expect(destroySystemTrayMock.mock.invocationCallOrder[0]).toBeLessThan( + appExitMock.mock.invocationCallOrder[0] + ) }) it('waits for pre-relaunch cleanup before exiting', async () => { diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 7424c4db8b1..e16b70b401a 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -12,6 +12,7 @@ import { isPwshAvailable } from '../pwsh' import { isWslAvailable, listWslDistros } from '../wsl' import { isGitBashAvailable } from '../git-bash' import { setUnreadDockBadgeCount } from '../dock/unread-badge' +import { destroySystemTray } from '../tray/system-tray' import { authorizeExternalPath } from './filesystem-auth' import { ensureDefaultFloatingWorkspacePath, @@ -214,6 +215,9 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp // Mark shutdown first because app.exit() can bypass the usual quit latch. await runBeforeRelaunchCleanup(options.onBeforeRelaunch) setTimeout(() => { + // Why: app.exit(0) skips before-quit/will-quit, so clean the Windows tray + // explicitly before relaunching to avoid a stale notification-area icon. + destroySystemTray() app.relaunch() app.exit(0) }, 150) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 9618b13434e..9ac65353391 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -510,6 +510,56 @@ describe('Store', () => { expect(ui.setupGuideBrowserMilestoneLegacyComplete).toBe(false) }) + it('defaults minimizeToTrayOnClose to false when unset', async () => { + const store = await createStore() + expect(store.getSettings().minimizeToTrayOnClose).toBe(false) + }) + + it('coerces loaded minimizeToTrayOnClose to false unless stored as true', async () => { + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + settings: { + minimizeToTrayOnClose: 'true' as unknown as boolean + } + }) + + const store = await createStore() + + expect(store.getSettings().minimizeToTrayOnClose).toBe(false) + }) + + it('persists minimizeToTrayOnClose true/false round-trip', async () => { + const store = await createStore() + store.updateSettings({ minimizeToTrayOnClose: true }) + expect(store.getSettings().minimizeToTrayOnClose).toBe(true) + store.flush() + expect((readDataFile() as PersistedState).settings.minimizeToTrayOnClose).toBe(true) + store.updateSettings({ minimizeToTrayOnClose: false }) + expect(store.getSettings().minimizeToTrayOnClose).toBe(false) + }) + + it('coerces non-boolean minimizeToTrayOnClose payloads to a strict boolean', async () => { + const store = await createStore() + // Why: a renderer-supplied non-bool must never persist as a truthy non-bool + // that would later read as "tray-minimize on". + store.updateSettings({ minimizeToTrayOnClose: 'true' as unknown as boolean }) + expect(store.getSettings().minimizeToTrayOnClose).toBe(false) + store.updateSettings({ minimizeToTrayOnClose: 1 as unknown as boolean }) + expect(store.getSettings().minimizeToTrayOnClose).toBe(false) + store.updateSettings({ minimizeToTrayOnClose: null as unknown as boolean }) + expect(store.getSettings().minimizeToTrayOnClose).toBe(false) + }) + + it('defaults trayMinimizeNoticeShown to false and persists it strictly', async () => { + const store = await createStore() + expect(store.getUI().trayMinimizeNoticeShown).toBe(false) + store.updateUI({ trayMinimizeNoticeShown: true }) + expect(store.getUI().trayMinimizeNoticeShown).toBe(true) + store.flush() + const reloaded = await createStore() + expect(reloaded.getUI().trayMinimizeNoticeShown).toBe(true) + }) + it('hides the setup guide sidebar entry for existing users backfilled as completed', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 8a140d4cd2f..0492a7b5582 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -2714,6 +2714,9 @@ export class Store { parsed.settings?.terminalCustomThemes ), appIcon: normalizeAppIconId(parsed.settings?.appIcon), + // Why: persisted settings can be user-edited or written by older + // builds; keep tray-minimize false unless the stored value is true. + minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), defaultTaskSource: taskProviderSettings.defaultTaskSource, visibleTaskProviders: taskProviderSettings.visibleTaskProviders, @@ -4498,6 +4501,12 @@ export class Store { options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { const sanitizedUpdates = { ...updates } + // Why: coerce strictly to boolean here (not at the IPC edge) so every write + // path is covered and a non-bool renderer payload can never persist a + // truthy non-bool that later reads as "tray-minimize on". + if ('minimizeToTrayOnClose' in updates) { + sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true + } if ('disabledTuiAgents' in updates) { sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) } @@ -4640,6 +4649,9 @@ export class Store { this.state.ui?.workspaceBoardColumnWidth ), syncTaskStatusFromWorkspaceBoard: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, + // Why: strict boolean coercion so a missing/legacy value reads as false + // (first-run notice still fires) rather than leaking a non-bool through. + trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true, markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth), visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds( this.state.ui?.visibleWorkspaceHostIds diff --git a/src/main/tray/system-tray.test.ts b/src/main/tray/system-tray.test.ts new file mode 100644 index 00000000000..e269d9307b2 --- /dev/null +++ b/src/main/tray/system-tray.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as SystemTrayModule from './system-tray' + +const { trayInstances, menuFromTemplateMock, createAppIconImageMock, resizedImage } = vi.hoisted( + () => { + const resizedImage = { resized: true } + return { + trayInstances: [] as FakeTray[], + menuFromTemplateMock: vi.fn((template: unknown) => ({ template })), + createAppIconImageMock: vi.fn(), + resizedImage + } + } +) + +class FakeTray { + setToolTip = vi.fn() + setContextMenu = vi.fn() + on = vi.fn() + destroy = vi.fn() + isDestroyed = vi.fn(() => false) + constructor(public readonly image: unknown) { + trayInstances.push(this) + } +} + +vi.mock('electron', () => ({ + Tray: FakeTray, + Menu: { buildFromTemplate: menuFromTemplateMock } +})) + +vi.mock('../app-icon', () => ({ + createAppIconImage: createAppIconImageMock +})) + +type TrayModule = typeof SystemTrayModule + +const originalPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +async function loadModule(): Promise { + vi.resetModules() + return import('./system-tray') +} + +type MenuItem = { label?: string; type?: string; click?: () => void } + +function builtMenuItems(): MenuItem[] { + return menuFromTemplateMock.mock.calls.at(-1)?.[0] as MenuItem[] +} + +beforeEach(() => { + trayInstances.length = 0 + menuFromTemplateMock.mockClear() + createAppIconImageMock.mockReset() + createAppIconImageMock.mockReturnValue({ resize: vi.fn(() => resizedImage) }) +}) + +afterEach(() => { + setPlatform(originalPlatform) +}) + +describe('createSystemTray', () => { + it('creates a tray with an Orca tooltip and Open/Quit menu on win32', async () => { + setPlatform('win32') + const { createSystemTray } = await loadModule() + const onOpen = vi.fn() + const onQuit = vi.fn() + + const tray = createSystemTray({ appIcon: 'classic', onOpen, onQuit }) + + expect(tray).not.toBeNull() + expect(trayInstances).toHaveLength(1) + expect(trayInstances[0].image).toBe(resizedImage) + expect(trayInstances[0].setToolTip).toHaveBeenCalledWith('Orca') + const items = builtMenuItems() + expect(items.map((i) => i.label)).toEqual(['Open Orca', undefined, 'Quit']) + expect(items[1].type).toBe('separator') + }) + + it('wires Open Orca, the tray click, and Quit to their callbacks', async () => { + setPlatform('win32') + const { createSystemTray } = await loadModule() + const onOpen = vi.fn() + const onQuit = vi.fn() + + createSystemTray({ appIcon: 'classic', onOpen, onQuit }) + + const items = builtMenuItems() + items.find((i) => i.label === 'Open Orca')?.click?.() + expect(onOpen).toHaveBeenCalledTimes(1) + + const clickHandler = trayInstances[0].on.mock.calls.find((c) => c[0] === 'click')?.[1] as + | (() => void) + | undefined + clickHandler?.() + expect(onOpen).toHaveBeenCalledTimes(2) + + items.find((i) => i.label === 'Quit')?.click?.() + expect(onQuit).toHaveBeenCalledTimes(1) + }) + + it('is idempotent: a second call does not create a duplicate tray', async () => { + setPlatform('win32') + const { createSystemTray } = await loadModule() + const opts = { appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() } + + const first = createSystemTray(opts) + const second = createSystemTray(opts) + + expect(trayInstances).toHaveLength(1) + expect(second).toBe(first) + }) + + it('is a no-op on non-win32 platforms', async () => { + setPlatform('darwin') + const { createSystemTray } = await loadModule() + + const tray = createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + + expect(tray).toBeNull() + expect(trayInstances).toHaveLength(0) + }) +}) + +describe('destroySystemTray', () => { + it('destroys an existing tray and is safe to call without one', async () => { + setPlatform('win32') + const { createSystemTray, destroySystemTray } = await loadModule() + createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + const created = trayInstances[0] + + destroySystemTray() + expect(created.destroy).toHaveBeenCalledTimes(1) + + // Second call with no live tray must not throw. + expect(() => destroySystemTray()).not.toThrow() + }) +}) diff --git a/src/main/tray/system-tray.ts b/src/main/tray/system-tray.ts new file mode 100644 index 00000000000..d531d2c596b --- /dev/null +++ b/src/main/tray/system-tray.ts @@ -0,0 +1,58 @@ +import { Menu, Tray } from 'electron' +import { createAppIconImage } from '../app-icon' +import { translateMain } from '../i18n/main-i18n' + +type SystemTrayOptions = { + /** App icon id from settings; the tray reuses the app icon image. */ + appIcon: unknown + /** Restore + show + focus the main window (recreating it if needed). */ + onOpen: () => void + /** Quit Orca for real (caller must set the quitting latch before quitting). */ + onQuit: () => void +} + +// Why: Electron's Tray is GC-collected and its icon vanishes if no live +// reference is kept, so hold it at module scope for the app's lifetime. +let tray: Tray | null = null + +// Why: on Windows the notification area expects a 16px icon; the app icon PNG +// is larger, so downscale to avoid a cropped/blurry tray glyph. +const TRAY_ICON_SIZE = 16 + +/** + * Creates the Windows system tray icon. No-op on macOS/Linux. Idempotent: a + * second call while a tray is alive returns the existing one instead of + * stacking a duplicate ghost icon. + */ +export function createSystemTray(opts: SystemTrayOptions): Tray | null { + if (process.platform !== 'win32') { + return null + } + if (tray && !tray.isDestroyed()) { + return tray + } + const image = createAppIconImage(opts.appIcon).resize({ + width: TRAY_ICON_SIZE, + height: TRAY_ICON_SIZE + }) + tray = new Tray(image) + tray.setToolTip('Orca') + const menu = Menu.buildFromTemplate([ + { label: translateMain('tray.openOrca', 'Open Orca'), click: () => opts.onOpen() }, + { type: 'separator' }, + { label: translateMain('tray.quit', 'Quit'), click: () => opts.onQuit() } + ]) + tray.setContextMenu(menu) + // Why: a left-click on the tray icon is the conventional Windows gesture to + // restore a minimized-to-tray app. + tray.on('click', () => opts.onOpen()) + return tray +} + +/** Destroys the tray icon if present. Safe to call repeatedly or with no tray. */ +export function destroySystemTray(): void { + if (tray && !tray.isDestroyed()) { + tray.destroy() + } + tray = null +} diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 6dd1d33d85d..be0d796c621 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -1,5 +1,5 @@ /* oxlint-disable max-lines */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { browserWindowMock, @@ -7,15 +7,22 @@ const { attachGuestPoliciesMock, buildFromTemplateMock, menuPopupMock, + notificationMock, + notificationShowMock, isMock } = vi.hoisted(() => { const menuPopupMock = vi.fn() + const notificationShowMock = vi.fn() return { browserWindowMock: vi.fn(), openExternalMock: vi.fn(), attachGuestPoliciesMock: vi.fn(), buildFromTemplateMock: vi.fn(() => ({ popup: menuPopupMock })), menuPopupMock, + notificationMock: vi.fn(function () { + return { show: notificationShowMock } + }), + notificationShowMock, isMock: { dev: false } } }) @@ -25,6 +32,7 @@ vi.mock('electron', () => ({ BrowserWindow: browserWindowMock, ipcMain: { on: vi.fn(), removeListener: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() }, Menu: { buildFromTemplate: buildFromTemplateMock }, + Notification: notificationMock, nativeTheme: { shouldUseDarkColors: false }, screen: { getPrimaryDisplay: () => ({ workAreaSize: { width: 1440, height: 900 } }) @@ -57,6 +65,8 @@ describe('createMainWindow', () => { attachGuestPoliciesMock.mockReset() buildFromTemplateMock.mockClear() menuPopupMock.mockClear() + notificationMock.mockClear() + notificationShowMock.mockClear() isMock.dev = false vi.mocked(ipcMain.on).mockReset() vi.mocked(ipcMain.removeListener).mockReset() @@ -2521,4 +2531,197 @@ describe('createMainWindow', () => { expect(browserWindowInstance.maximize).toHaveBeenCalledTimes(1) expect(browserWindowInstance.show).toHaveBeenCalledTimes(1) }) + + describe('minimize to tray on close (win32)', () => { + const originalPlatform = process.platform + + function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + } + + type CloseFixture = { + windowHandlers: Record void> + webContents: { send: ReturnType } + instance: { hide: ReturnType; isMinimized: ReturnType } + } + + function setupCloseWindow(): CloseFixture { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isCrashed: vi.fn(() => false), + id: 1 + } + const instance = { + webContents, + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => false), + isFullScreen: vi.fn(() => false), + isMinimized: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return instance + }) + return { windowHandlers, webContents, instance } + } + + function makeStore(minimizeToTrayOnClose: boolean, trayMinimizeNoticeShown: boolean) { + return { + getUI: vi.fn(() => ({ trayMinimizeNoticeShown })), + getSettings: vi.fn(() => ({ windowBackgroundBlur: false, minimizeToTrayOnClose })), + updateUI: vi.fn() + } + } + + afterEach(() => { + setPlatform(originalPlatform) + }) + + it('hides to the tray instead of closing when the setting is on', () => { + setPlatform('win32') + const { windowHandlers, webContents, instance } = setupCloseWindow() + const store = makeStore(true, true) + + createMainWindow(store as never, { getIsQuitting: () => false }) + const preventDefault = vi.fn() + windowHandlers.close({ preventDefault } as never) + + expect(preventDefault).toHaveBeenCalled() + expect(instance.hide).toHaveBeenCalledTimes(1) + expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', expect.anything()) + // Notice already shown, so it must not fire again. + expect(notificationMock).not.toHaveBeenCalled() + }) + + it('keeps the normal close flow when the setting is off', () => { + setPlatform('win32') + const { windowHandlers, webContents, instance } = setupCloseWindow() + const store = makeStore(false, true) + + createMainWindow(store as never, { getIsQuitting: () => false }) + windowHandlers.close({ preventDefault: vi.fn() } as never) + + expect(instance.hide).not.toHaveBeenCalled() + expect(webContents.send).toHaveBeenCalledWith('window:close-requested', { + isQuitting: false + }) + }) + + it('does not hide on a real quit even with the setting on', () => { + setPlatform('win32') + const { windowHandlers, webContents, instance } = setupCloseWindow() + const store = makeStore(true, true) + + createMainWindow(store as never, { getIsQuitting: () => true }) + windowHandlers.close({ preventDefault: vi.fn() } as never) + + expect(instance.hide).not.toHaveBeenCalled() + expect(webContents.send).toHaveBeenCalledWith('window:close-requested', { + isQuitting: true + }) + }) + + it('does not hide when the renderer process is gone', () => { + setPlatform('win32') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { windowHandlers, instance } = setupCloseWindow() + const store = makeStore(true, true) + + createMainWindow(store as never, { getIsQuitting: () => false }) + windowHandlers['render-process-gone']?.( + {} as never, + { reason: 'crashed', exitCode: 5 } as never + ) + const preventDefault = vi.fn() + windowHandlers.close({ preventDefault } as never) + + expect(instance.hide).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + consoleError.mockRestore() + }) + + it('shows the first-run notification once and persists the flag', () => { + setPlatform('win32') + const { windowHandlers } = setupCloseWindow() + const store = makeStore(true, false) + + createMainWindow(store as never, { getIsQuitting: () => false }) + windowHandlers.close({ preventDefault: vi.fn() } as never) + + expect(notificationMock).toHaveBeenCalledTimes(1) + expect(notificationShowMock).toHaveBeenCalledTimes(1) + expect(store.updateUI).toHaveBeenCalledWith({ trayMinimizeNoticeShown: true }) + }) + + it('leaves the close handler unchanged off win32', () => { + setPlatform('darwin') + const { windowHandlers, webContents, instance } = setupCloseWindow() + const store = makeStore(true, true) + + createMainWindow(store as never, { getIsQuitting: () => false }) + windowHandlers.close({ preventDefault: vi.fn() } as never) + + expect(instance.hide).not.toHaveBeenCalled() + expect(webContents.send).toHaveBeenCalledWith('window:close-requested', { + isQuitting: false + }) + }) + + // Why: on Windows the renderer-drawn X routes through window:request-close, + // not the native close event — regression guard for the bug where the app + // quit instead of hiding because the guard only covered the native event. + function captureIpcHandlers(): Record void> { + const ipcHandlers: Record void> = {} + vi.mocked(ipcMain.on).mockImplementation((channel, handler) => { + ipcHandlers[channel] = handler as (...args: any[]) => void + return ipcMain + }) + return ipcHandlers + } + + it('hides to the tray when the renderer-drawn X requests close', () => { + setPlatform('win32') + const ipcHandlers = captureIpcHandlers() + const { webContents, instance } = setupCloseWindow() + const store = makeStore(true, true) + + createMainWindow(store as never, { getIsQuitting: () => false }) + ipcHandlers['window:request-close']?.() + + expect(instance.hide).toHaveBeenCalledTimes(1) + expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', expect.anything()) + }) + + it('forwards window:request-close to the renderer when the setting is off', () => { + setPlatform('win32') + const ipcHandlers = captureIpcHandlers() + const { webContents, instance } = setupCloseWindow() + const store = makeStore(false, true) + + createMainWindow(store as never, { getIsQuitting: () => false }) + ipcHandlers['window:request-close']?.() + + expect(instance.hide).not.toHaveBeenCalled() + expect(webContents.send).toHaveBeenCalledWith('window:close-requested', { + isQuitting: false + }) + }) + }) }) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 0ff617b1ba1..d0cc4332fcb 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -1,11 +1,21 @@ /* oxlint-disable max-lines */ -import { app, BrowserWindow, ipcMain, Menu, nativeTheme, screen, shell } from 'electron' +import { + app, + BrowserWindow, + ipcMain, + Menu, + nativeTheme, + Notification, + screen, + shell +} from 'electron' import { join } from 'path' import { is } from '@electron-toolkit/utils' import type { Store } from '../persistence' import { getAppIconPath } from '../app-icon' import { browserManager } from '../browser/browser-manager' import { browserSessionRegistry } from '../browser/browser-session-registry' +import { translateMain } from '../i18n/main-i18n' import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl @@ -932,7 +942,50 @@ export function createMainWindow( let windowCloseConfirmed = false const confirmCloseChannel = 'window:confirm-close' + // Why: Windows minimize-to-tray. Hides the window instead of closing when the + // setting is on, this isn't a real quit (Ctrl+Q / tray "Quit" set + // getIsQuitting), and the renderer is alive. Returns true when it handled the + // close by hiding, so callers skip their normal close path. Shared by BOTH the + // renderer-drawn X (window:request-close) and the native close event (Alt+F4). + const hideToTrayIfEnabled = (): boolean => { + const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false + if ( + process.platform !== 'win32' || + rendererProcessGone || + isRendererCrashed || + opts?.getIsQuitting?.() === true || + store?.getSettings().minimizeToTrayOnClose !== true + ) { + return false + } + mainWindow.hide() + // Why: tell the user once that closing only hid the window; the persisted + // flag stops the notice from repeating on every later minimize. + if (store.getUI().trayMinimizeNoticeShown !== true) { + try { + new Notification({ + title: 'Orca', + body: translateMain( + 'tray.minimizeNotice.body', + 'Orca is still running in the system tray' + ) + }).show() + } catch { + // Notification is best-effort — never block hiding the window. + } + store.updateUI({ trayMinimizeNoticeShown: true }) + } + return true + } + mainWindow.on('close', (e) => { + // Why: Alt+F4 and programmatic closes reach the native event; apply the same + // minimize-to-tray guard the renderer-drawn X uses via onRequestClose. + if (!windowCloseConfirmed && hideToTrayIfEnabled()) { + e.preventDefault() + return + } + const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false if (windowCloseConfirmed) { windowCloseConfirmed = false // Why: past this point Electron/OS may emit resize/move/unmaximize as @@ -947,7 +1000,6 @@ export function createMainWindow( } return } - const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false if (rendererProcessGone || isRendererCrashed) { // Why: after a native renderer crash the renderer cannot answer // window:close-requested. Let Cmd+Q / OS close complete instead of @@ -1016,9 +1068,16 @@ export function createMainWindow( // with windowCloseConfirmed = true. const requestCloseChannel = 'window:request-close' const onRequestClose = (): void => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('window:close-requested', { isQuitting: false }) + if (mainWindow.isDestroyed()) { + return } + // Why: the renderer-drawn X on Windows routes here (not the native close + // event), so the minimize-to-tray guard must run on this path too — hide + // instead of asking the renderer to close. + if (hideToTrayIfEnabled()) { + return + } + mainWindow.webContents.send('window:close-requested', { isQuitting: false }) } // Why: the ··· button in the renderer-drawn title bar on Windows pops up // the application menu at the cursor position, replicating the Alt-key diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index aed005c6879..8a392e30515 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -31,6 +31,7 @@ import { getSidebarEntries, getStatusBarEntries, getStatusBarToggles, + getSystemTrayEntries, getThemeEntries, getTitlebarEntries, getTypographyEntries, @@ -41,7 +42,8 @@ import { TerminalAppearanceSection } from './TerminalAppearanceSection' import type { UseGhosttyImportReturn } from './useGhosttyImport' import type { UseWarpThemeImportReturn } from './useWarpThemeImport' import { AppIconSelector } from './AppIconSelector' -import { isWebClientLocation } from '@/hooks/useSettingsNavigationMetadata' +import { getRendererAppPlatform } from '@/lib/renderer-app-platform' +import { isWebClientLocation } from '@/lib/web-client-location' import { getUiLanguageChoiceLabel, SHOW_UI_LANGUAGE_SETTING, @@ -99,6 +101,10 @@ export function AppearancePane({ warpThemes }: AppearancePaneProps): React.JSX.Element { const searchQuery = useAppStore((state) => state.settingsSearchQuery) + const isWebClient = isWebClientLocation() + // Why: the system tray behavior is desktop-Electron Windows-only; a Windows + // browser web client has no local tray to control. + const isDesktopWindows = getRendererAppPlatform() === 'win32' && !isWebClient const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in') const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out') const statusBarItems = useAppStore((state) => state.statusBarItems) @@ -106,8 +112,9 @@ export function AppearancePane({ const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles()) const terminalAppearanceSearchEntries = getTerminalAppearanceSearchEntries({ - showWarpImport: !isWebClientLocation() + showWarpImport: !isWebClient }) + const systemTrayEntries = getSystemTrayEntries({ showSystemTray: isDesktopWindows }) const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry() const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry() const visibleSections = [ @@ -362,6 +369,42 @@ export function AppearancePane({
) : null, + isDesktopWindows && matchesSettingsSearch(searchQuery, systemTrayEntries) ? ( +
+ + +
+ + + updateSettings({ minimizeToTrayOnClose: !settings.minimizeToTrayOnClose }) + } + /> + +
+
+ ) : null, matchesSettingsSearch(searchQuery, getStatusBarEntries()) ? (
[ + { + title: translate( + 'auto.components.settings.appearance.search.9a115966d3', + 'Minimize to Tray on Close' + ), + description: translate( + 'auto.components.settings.appearance.search.4d5b9427b5', + 'When enabled, closing the window keeps Orca running in the system tray instead of quitting.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', { + englishOnly: true + }), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.system', + 'system tray', + { englishOnly: true } + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.minimize', + 'minimize', + { englishOnly: true } + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.tray.close', 'close', { + englishOnly: true + }), + ...translateSearchKeyword('auto.components.settings.appearance.search.e5bc35d59e', 'window'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.notification', + 'notification area', + { englishOnly: true } + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.background', + 'background', + { englishOnly: true } + ) + ] + } +]) + +type SystemTraySearchOptions = { + showSystemTray?: boolean +} + +function shouldShowSystemTrayEntries(options: SystemTraySearchOptions): boolean { + return ( + options.showSystemTray ?? + // Why: this setting controls Electron's Windows tray only. A Windows web + // browser can report win32, but it has no local tray to affect. + (getRendererAppPlatform() === 'win32' && !isWebClientLocation()) + ) +} + +export function getSystemTrayEntries(options: SystemTraySearchOptions = {}): SettingsSearchEntry[] { + return shouldShowSystemTrayEntries(options) ? getSystemTrayEntryCatalog() : [] +} + type AppearancePaneSearchOptions = { showWarpImport?: boolean + showSystemTray?: boolean } function buildAppearancePaneSearchEntries( @@ -201,22 +263,16 @@ function buildAppearancePaneSearchEntries( ...getTitlebarEntries(), ...getStatusBarEntries(), ...getSidebarEntries(), - ...getAppIconEntries() + ...getAppIconEntries(), + ...getSystemTrayEntries(options) ] } -const getAppearancePaneSearchEntriesWithWarp = createLocalizedCatalog(() => - buildAppearancePaneSearchEntries({ showWarpImport: true }) -) - -const getAppearancePaneSearchEntriesWithoutWarp = createLocalizedCatalog(() => - buildAppearancePaneSearchEntries({ showWarpImport: false }) -) - export function getAppearancePaneSearchEntries( options: AppearancePaneSearchOptions = {} ): SettingsSearchEntry[] { - return (options.showWarpImport ?? true) - ? getAppearancePaneSearchEntriesWithWarp() - : getAppearancePaneSearchEntriesWithoutWarp() + return buildAppearancePaneSearchEntries({ + showWarpImport: options.showWarpImport ?? true, + showSystemTray: options.showSystemTray + }) } diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index 7b1246694ed..56ca5e3c283 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -131,6 +131,16 @@ describe('getTerminalPaneSearchEntries', () => { expect(webEntries.some((entry) => entry.title === 'Import from Ghostty')).toBe(true) }) + it('includes the system tray appearance entry only when desktop tray controls are shown', () => { + const desktopEntries = getAppearancePaneSearchEntries({ showSystemTray: true }) + const webEntries = getAppearancePaneSearchEntries({ showSystemTray: false }) + + expect(desktopEntries.some((entry) => entry.title === 'Minimize to Tray on Close')).toBe(true) + expect(webEntries.some((entry) => entry.title === 'Minimize to Tray on Close')).toBe(false) + expect(matchesSettingsSearch('tray', desktopEntries)).toBe(true) + expect(matchesSettingsSearch('tray', webEntries)).toBe(false) + }) + it('keeps sidebar shortcut restore settings in the Appearance search index', () => { const automationsEntry = getSidebarEntries().find( (entry) => entry.title === 'Show Automations Button' diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 6a06b749829..64e36fc67d2 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -70,18 +70,14 @@ import { getShortcutsPaneSearchEntries } from '@/components/settings/shortcuts-s import { getStatsPaneSearchEntries } from '@/components/stats/stats-search' import { getExperimentalPaneSearchEntries } from '@/components/settings/experimental-search' import { getRepositoryPaneSearchEntries } from '@/components/settings/repository-search' +import { isWebClientLocation } from '@/lib/web-client-location' import { getCachedWindowsTerminalCapabilities, getWindowsTerminalCapabilityOwnerKey } from '@/lib/windows-terminal-capabilities' import { translate } from '@/i18n/i18n' -export function isWebClientLocation(): boolean { - return ( - Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) || - window.location.pathname.endsWith('/web-index.html') - ) -} +export { isWebClientLocation } from '@/lib/web-client-location' export function buildSettingsNavigationMetadata({ isMac, @@ -333,7 +329,10 @@ export function buildSettingsNavigationMetadata({ 'Theme, zoom, app and terminal appearance, sidebars, and status bar.' ), icon: Palette, - searchEntries: getAppearancePaneSearchEntries(), + searchEntries: getAppearancePaneSearchEntries({ + showWarpImport: showDesktopOnlySettings, + showSystemTray: showDesktopOnlySettings && isWindows + }), group: 'interface' }, { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 81fc0059888..323a469fce0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -58,6 +58,13 @@ "window": "Window", "help": "Help" }, + "tray": { + "openOrca": "Open Orca", + "quit": "Quit", + "minimizeNotice": { + "body": "Orca is still running in the system tray" + } + }, "worktreeJumpPalette": { "matchLabel": { "comment": "Comment", @@ -4383,6 +4390,9 @@ "5f5142a62a": "Previous icon" }, "AppearancePane": { + "872af9556e": "System Tray", + "2edf606c46": "Minimize to Tray on Close", + "b707773a0d": "When enabled, closing the window keeps Orca running in the system tray instead of quitting.", "3057983501": "Unassigned", "0cd9b8228f": "Choose the app icon shown in the Dock and window switcher.", "ca1590d42f": "App Icon", @@ -6463,6 +6473,8 @@ }, "appearance": { "search": { + "9a115966d3": "Minimize to Tray on Close", + "4d5b9427b5": "When enabled, closing the window keeps Orca running in the system tray instead of quitting.", "468448bba4": "watercolor", "f586abfa35": "blue", "651f35b2c6": "switcher", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9134c7249ff..016e565aec1 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -58,6 +58,13 @@ "window": "Ventana", "help": "Ayuda" }, + "tray": { + "openOrca": "Abrir Orca", + "quit": "Salir", + "minimizeNotice": { + "body": "Orca sigue ejecutándose en la bandeja del sistema" + } + }, "worktreeJumpPalette": { "matchLabel": { "comment": "Comentario", @@ -4426,7 +4433,10 @@ "tintOpacity": "Intensidad del tinte", "tintOpacityDescription": "Controla con qué fuerza se mezcla el tinte en la barra lateral." }, - "workspaceCardLayoutGuidance": "Usa el menú de opciones de la barra lateral de espacios de trabajo > Diseño de tarjeta > Compacto." + "workspaceCardLayoutGuidance": "Usa el menú de opciones de la barra lateral de espacios de trabajo > Diseño de tarjeta > Compacto.", + "872af9556e": "Bandeja del sistema", + "2edf606c46": "Minimizar a la bandeja al cerrar", + "b707773a0d": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir." }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -6549,7 +6559,9 @@ "cardLayout": "diseño de tarjeta", "workspaceOptions": "opciones de espacios de trabajo", "detailed": "detallado" - } + }, + "9a115966d3": "Minimizar a la bandeja al cerrar", + "4d5b9427b5": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir." } }, "auto": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e8ba30da972..8e379f8a78a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -58,6 +58,13 @@ "window": "ウィンドウ", "help": "ヘルプ" }, + "tray": { + "openOrca": "Orcaを開く", + "quit": "終了", + "minimizeNotice": { + "body": "Orcaはシステムトレイで実行中です" + } + }, "worktreeJumpPalette": { "matchLabel": { "comment": "コメント", @@ -4411,7 +4418,10 @@ "tintOpacity": "色合いの強さ", "tintOpacityDescription": "サイドバーに色合いをどの程度強く混ぜるかを調整します。" }, - "workspaceCardLayoutGuidance": "ワークスペースサイドバーのオプションメニュー > カードレイアウト > コンパクト を使用します。" + "workspaceCardLayoutGuidance": "ワークスペースサイドバーのオプションメニュー > カードレイアウト > コンパクト を使用します。", + "872af9556e": "システムトレイ", + "2edf606c46": "閉じるときにトレイへ最小化", + "b707773a0d": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。" }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -6571,7 +6581,9 @@ "cardLayout": "カードレイアウト", "workspaceOptions": "ワークスペースオプション", "detailed": "詳細" - } + }, + "9a115966d3": "閉じるときにトレイへ最小化", + "4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。" } }, "auto": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 568e3ea7279..8654c105981 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -58,6 +58,13 @@ "window": "창", "help": "도움말" }, + "tray": { + "openOrca": "Orca 열기", + "quit": "종료", + "minimizeNotice": { + "body": "Orca가 시스템 트레이에서 계속 실행 중입니다" + } + }, "worktreeJumpPalette": { "matchLabel": { "comment": "댓글", @@ -4368,6 +4375,9 @@ "5f5142a62a": "이전 아이콘" }, "AppearancePane": { + "872af9556e": "시스템 트레이", + "2edf606c46": "닫을 때 트레이로 최소화", + "b707773a0d": "활성화하면 창을 닫아도 Orca가 종료되지 않고 시스템 트레이에서 계속 실행됩니다.", "3057983501": "할당되지 않음", "0cd9b8228f": "Dock 및 창 전환기에 표시된 앱 아이콘을 선택합니다.", "ca1590d42f": "앱 아이콘", @@ -6524,6 +6534,8 @@ "title": "왼쪽 사이드바 모양", "description": "왼쪽 사이드바를 terminal에 맞추거나 기본값을 유지하거나 은은한 색조를 적용합니다." }, + "9a115966d3": "닫을 때 트레이로 최소화", + "4d5b9427b5": "활성화하면 창을 닫아도 Orca가 종료되지 않고 시스템 트레이에서 계속 실행됩니다.", "workspaceCardLayout": { "title": "워크스페이스 카드 레이아웃", "description": "워크스페이스 사이드바 옵션 메뉴에서 워크스페이스 카드를 컴팩트 또는 상세 보기로 전환합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 67d01ddfa5e..9525182f4bf 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -58,6 +58,13 @@ "window": "窗口", "help": "帮助" }, + "tray": { + "openOrca": "打开 Orca", + "quit": "退出", + "minimizeNotice": { + "body": "Orca 仍在系统托盘中运行" + } + }, "worktreeJumpPalette": { "matchLabel": { "comment": "评论", @@ -4411,7 +4418,10 @@ "tintOpacity": "色调强度", "tintOpacityDescription": "控制色调混入边栏的强度。" }, - "workspaceCardLayoutGuidance": "使用工作区侧边栏选项菜单 > 卡片布局 > 紧凑。" + "workspaceCardLayoutGuidance": "使用工作区侧边栏选项菜单 > 卡片布局 > 紧凑。", + "872af9556e": "系统托盘", + "2edf606c46": "关闭时最小化到托盘", + "b707773a0d": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。" }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -6534,7 +6544,9 @@ "cardLayout": "卡片布局", "workspaceOptions": "工作区选项", "detailed": "详细" - } + }, + "9a115966d3": "关闭时最小化到托盘", + "4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。" } }, "auto": { diff --git a/src/renderer/src/lib/web-client-location.ts b/src/renderer/src/lib/web-client-location.ts new file mode 100644 index 00000000000..26c7e70bb21 --- /dev/null +++ b/src/renderer/src/lib/web-client-location.ts @@ -0,0 +1,9 @@ +export function isWebClientLocation(): boolean { + if (typeof window === 'undefined') { + return false + } + return ( + Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) || + window.location.pathname.endsWith('/web-index.html') + ) +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index e1711b29b62..00da564002d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -250,6 +250,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { // focus-follows-mouse never happens unexpectedly. terminalFocusFollowsMouse: false, windowBackgroundBlur: false, + minimizeToTrayOnClose: false, terminalClipboardOnSelect: false, terminalAllowOsc52Clipboard: false, claudeAgentTeamsMode: 'off', @@ -472,6 +473,7 @@ export function getDefaultUIState(): PersistedUIState { setupGuideBrowserMilestoneMigrated: true, setupGuideBrowserMilestoneLegacyComplete: false, browserImportHintHidden: false, + trayMinimizeNoticeShown: false, mobileEmulatorTabIntroDismissed: false, mobileEmulatorAgentSetupDismissed: false, // Why: brand-new profiles never saw recent project ordering; only upgraded diff --git a/src/shared/types.ts b/src/shared/types.ts index 82db4123b0b..9c72b816e17 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2419,6 +2419,10 @@ export type GlobalSettings = { terminalCursorOpacity?: number terminalQuickCommands?: TerminalQuickCommand[] windowBackgroundBlur?: boolean + /** Why: Windows-only. When on, the close (X) button hides the window to the + * system tray instead of quitting Orca; off keeps the default quit-on-close. + * The tray icon itself is always present on Windows regardless of this flag. */ + minimizeToTrayOnClose?: boolean /** Why: Windows terminals conventionally use right-click as a paste gesture. * The setting stays Windows-only so macOS/Linux keep their existing context * menu behavior and users can still reach the menu with Ctrl+right-click. */ @@ -3102,6 +3106,9 @@ export type PersistedUIState = { /** User-dismissed browser import hint in the browser toolbar. Import remains * available from Settings > Browser and the toolbar overflow menu. */ browserImportHintHidden?: boolean + /** Why: Windows-only. Set once after the window first hides to the system + * tray, so the "Orca is still running" notification shows only on first use. */ + trayMinimizeNoticeShown?: boolean /** User dismissed the first-run Mobile Emulator intro (Keep, Hide, or close). * Reversible only by re-enabling the feature in Settings. */ mobileEmulatorTabIntroDismissed?: boolean From 6d6af7af1a46502ec90174597c50e154f1519b7e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:50:11 -0700 Subject: [PATCH 013/163] Support browser panes on headless remote Orca servers (#5412) Co-authored-by: Orca --- config/electron-builder.config.cjs | 16 +- src/main/browser/browser-backend.ts | 23 + src/main/browser/browser-manager.ts | 32 + src/main/browser/offscreen-browser-backend.ts | 165 +++ src/main/index.ts | 19 + .../headless-tab-group-split-layout.test.ts | 213 ++++ .../headless-tab-group-split-layout.ts | 218 ++++ .../headless-terminal-split-layout.test.ts | 101 ++ .../runtime/headless-terminal-split-layout.ts | 59 + src/main/runtime/orca-runtime-browser.test.ts | 128 ++ src/main/runtime/orca-runtime-browser.ts | 79 +- src/main/runtime/orca-runtime.test.ts | 417 ++++++- src/main/runtime/orca-runtime.ts | 1043 +++++++++++++++-- .../runtime/rpc/methods/browser-schemas.ts | 5 +- .../rpc/methods/session-tabs-schemas.test.ts | 67 ++ .../rpc/methods/session-tabs-schemas.ts | 162 +++ src/main/runtime/rpc/methods/session-tabs.ts | 109 +- .../startup/ensure-virtual-display.test.ts | 149 +++ src/main/startup/ensure-virtual-display.ts | 173 +++ .../components/browser-pane/BrowserPane.tsx | 13 +- .../settings/RuntimeEnvironmentsPane.test.ts | 28 + .../settings/RuntimeEnvironmentsPane.tsx | 12 +- ...WorktreeCard.ssh-reconnect-prompt.test.tsx | 30 + .../src/components/sidebar/WorktreeCard.tsx | 40 +- src/renderer/src/components/sidebar/index.tsx | 33 + .../status-bar/RuntimeHostStatusRow.tsx | 11 +- .../status-bar/SshStatusSegment.test.ts | 8 +- .../status-bar/SshStatusSegment.tsx | 26 +- .../src/components/tab-bar/TabBar.tsx | 1 + .../useTabGroupWorkspaceModel.focus.test.ts | 53 + .../tab-group/useTabGroupWorkspaceModel.ts | 76 +- .../components/terminal-pane/TerminalPane.tsx | 53 +- ...erminal-live-layout-reconciliation.test.ts | 37 +- .../terminal-live-layout-reconciliation.ts | 20 + src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 8 +- src/renderer/src/i18n/locales/ja.json | 8 +- src/renderer/src/i18n/locales/ko.json | 8 +- src/renderer/src/i18n/locales/zh.json | 8 +- .../src/runtime/remote-server-parity.test.ts | 436 +++++++ .../remote-terminal-layout-resolution.test.ts | 86 ++ .../remote-terminal-layout-resolution.ts | 98 ++ .../src/runtime/sync-runtime-graph.ts | 40 +- .../src/runtime/web-runtime-session.test.ts | 59 +- .../src/runtime/web-runtime-session.ts | 146 ++- .../runtime/web-session-close-intent.test.ts | 43 + .../src/runtime/web-session-close-intent.ts | 92 ++ .../src/runtime/web-session-focus-intent.ts | 32 + .../src/runtime/web-session-tabs-sync.test.ts | 169 +++ .../src/runtime/web-session-tabs-sync.ts | 157 ++- src/renderer/src/store/slices/browser.test.ts | 34 +- src/renderer/src/store/slices/browser.ts | 20 +- .../store/slices/cmd-j-create-actions.test.ts | 13 +- src/renderer/src/store/slices/tabs.test.ts | 30 + src/renderer/src/store/slices/tabs.ts | 51 + src/renderer/src/store/slices/terminals.ts | 11 + src/shared/protocol-version.ts | 4 + src/shared/runtime-types.ts | 5 + src/shared/types.ts | 2 + src/shared/workspace-session-schema.ts | 1 + 60 files changed, 4845 insertions(+), 343 deletions(-) create mode 100644 src/main/browser/browser-backend.ts create mode 100644 src/main/browser/offscreen-browser-backend.ts create mode 100644 src/main/runtime/headless-tab-group-split-layout.test.ts create mode 100644 src/main/runtime/headless-tab-group-split-layout.ts create mode 100644 src/main/runtime/headless-terminal-split-layout.test.ts create mode 100644 src/main/runtime/headless-terminal-split-layout.ts create mode 100644 src/main/runtime/rpc/methods/session-tabs-schemas.test.ts create mode 100644 src/main/runtime/rpc/methods/session-tabs-schemas.ts create mode 100644 src/main/startup/ensure-virtual-display.test.ts create mode 100644 src/main/startup/ensure-virtual-display.ts create mode 100644 src/renderer/src/runtime/remote-server-parity.test.ts create mode 100644 src/renderer/src/runtime/remote-terminal-layout-resolution.test.ts create mode 100644 src/renderer/src/runtime/remote-terminal-layout-resolution.ts create mode 100644 src/renderer/src/runtime/web-session-close-intent.test.ts create mode 100644 src/renderer/src/runtime/web-session-close-intent.ts create mode 100644 src/renderer/src/runtime/web-session-focus-intent.ts diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index ab65eacb0a2..6a7acf0f038 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -278,12 +278,24 @@ module.exports = { deb: { packageName: 'orca-ide', artifactName: 'orca-ide_${version}_${arch}.${ext}', - depends: ['python3', 'python3-gi', 'gir1.2-atspi-2.0', 'at-spi2-core', 'xdotool', 'xclip'] + // Why: xvfb lets the bundled `orca serve` CLI run browser panes on a headless + // Linux host — Chromium needs a display server even for offscreen rendering, + // and serve starts Xvfb itself when present (see ensure-virtual-display.ts). + depends: ['python3', 'python3-gi', 'gir1.2-atspi-2.0', 'at-spi2-core', 'xdotool', 'xclip', 'xvfb'] }, rpm: { packageName: 'orca-ide', artifactName: 'orca-ide-${version}.${arch}.${ext}', - depends: ['python3', 'python3-gobject', 'at-spi2-core', 'xdotool', 'xclip'] + // Why: see deb depends. RPM distros ship Xvfb as xorg-x11-server-Xvfb (there + // is no `xvfb` package), so the name differs from the deb here. + depends: [ + 'python3', + 'python3-gobject', + 'at-spi2-core', + 'xdotool', + 'xclip', + 'xorg-x11-server-Xvfb' + ] }, beforeBuild: electronBuilderNativeRebuild, // Why: must be true so that electron-builder rebuilds native modules diff --git a/src/main/browser/browser-backend.ts b/src/main/browser/browser-backend.ts new file mode 100644 index 00000000000..697eb7c178f --- /dev/null +++ b/src/main/browser/browser-backend.ts @@ -0,0 +1,23 @@ +// Why: browser pages can be backed two ways. A desktop renderer mounts an +// Electron (renderer backend). A headless orca serve has no renderer +// window, so it backs pages with main-process offscreen WebContents (offscreen +// backend). Both register the page's WebContents into BrowserManager, so every +// downstream command (agent-browser automation, screencast, input) resolves a +// WebContents uniformly regardless of how the page was created. This interface +// isolates the only step that actually differs: tab creation and teardown. + +export type BrowserBackendCreateTab = { + url: string + worktreeId?: string + profileId?: string +} + +export type BrowserBackend = { + /** Create a browser page and register its WebContents. Returns the page id. */ + createTab(params: BrowserBackendCreateTab): Promise<{ browserPageId: string }> + /** Tear down a browser page created by this backend. */ + closeTab(browserPageId: string): Promise + /** Tear down every page this backend owns (process shutdown). Optional — + * renderer-hosted backends are torn down with their window. */ + destroyAll?(): void +} diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 1a10542cbbe..054d83eb851 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -827,6 +827,38 @@ export class BrowserManager { this.annotationViewportBridgeOpsByTabId.delete(browserTabId) } + // Why: headless orca serve has no renderer window to mount a , so its + // browser pages are backed by main-process offscreen WebContents instead. This + // registers such a page into the same resolution maps the bridge/screencast/ + // input handlers read, but skips the webview-only guards and the renderer setup + // (context menu, grab shortcut, etc.) that assume a renderer-hosted guest. + registerOffscreenGuest({ + browserPageId, + worktreeId, + sessionProfileId, + webContentsId + }: { + browserPageId: string + worktreeId?: string + sessionProfileId?: string | null + webContentsId: number + }): void { + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + return + } + const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) + if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { + this.retireStaleGuestWebContents(previousWebContentsId) + } + this.webContentsIdByTabId.set(browserPageId, webContentsId) + this.tabIdByWebContentsId.set(webContentsId, browserPageId) + this.sessionProfileIdByPageId.set(browserPageId, sessionProfileId ?? null) + if (worktreeId) { + this.worktreeIdByTabId.set(browserPageId, worktreeId) + } + } + unregisterAll(): void { // Cancel all active grab ops before tearing down registrations this.grabSessionController.cancelAll('evicted') diff --git a/src/main/browser/offscreen-browser-backend.ts b/src/main/browser/offscreen-browser-backend.ts new file mode 100644 index 00000000000..7eeed286364 --- /dev/null +++ b/src/main/browser/offscreen-browser-backend.ts @@ -0,0 +1,165 @@ +import { randomUUID } from 'crypto' +import { BrowserWindow } from 'electron' +import { ORCA_BROWSER_PARTITION } from '../../shared/constants' +import type { BrowserBackend, BrowserBackendCreateTab } from './browser-backend' +import type { BrowserManager } from './browser-manager' +import { browserSessionRegistry } from './browser-session-registry' + +// Why: headless orca serve has no renderer window to host a , so each +// browser page is backed by a main-process offscreen BrowserWindow. The window +// is never shown — it exists only so its WebContents can be driven over CDP and +// streamed via the existing screencast path. Verified on macOS and on headless +// Linux under Xvfb (Electron --headless segfaults; a virtual display is +// required there — provisioned in the serve image, not by this code). + +const DEFAULT_VIEWPORT_WIDTH = 1280 +const DEFAULT_VIEWPORT_HEIGHT = 800 +const LOAD_TIMEOUT_MS = 30_000 + +export class OffscreenBrowserBackend implements BrowserBackend { + private readonly windowsByPageId = new Map() + + constructor(private readonly browserManager: BrowserManager) {} + + async createTab(params: BrowserBackendCreateTab): Promise<{ browserPageId: string }> { + const browserPageId = randomUUID() + // Why: profiles map to Electron partitions; using the profile's partition + // makes cookies/storage persist in the same SQLite DB the desktop path uses. + const profile = params.profileId + ? browserSessionRegistry.getProfile(params.profileId) + : browserSessionRegistry.getDefaultProfile() + const partition = profile?.partition ?? ORCA_BROWSER_PARTITION + + const win = new BrowserWindow({ + show: false, + width: DEFAULT_VIEWPORT_WIDTH, + height: DEFAULT_VIEWPORT_HEIGHT, + webPreferences: { + partition, + sandbox: true, + contextIsolation: true, + nodeIntegration: false + } + }) + + this.windowsByPageId.set(browserPageId, win) + + // Why: if the offscreen window is destroyed out from under us (crash, app + // teardown), drop the registry entry so commands fail cleanly instead of + // resolving a dead WebContents. + win.webContents.once('destroyed', () => { + this.windowsByPageId.delete(browserPageId) + this.browserManager.unregisterGuest(browserPageId) + }) + + // Why: register the guest and return immediately so the new tab appears + // without waiting for the page to finish loading. Previously createTab + // awaited the full navigation, so clicking "New Browser Tab" did nothing for + // up to a second on real URLs. The page loads asynchronously and streams + // once it paints; a failed load leaves the (usable) tab open, matching how a + // normal browser tab survives a failed navigation. + this.browserManager.registerOffscreenGuest({ + browserPageId, + worktreeId: params.worktreeId, + sessionProfileId: profile?.id ?? null, + webContentsId: win.webContents.id + }) + + const url = params.url || 'about:blank' + void this.loadUrl(win, url).catch((error) => { + console.warn( + '[offscreen-browser] page load failed:', + error instanceof Error ? error.message : String(error) + ) + }) + + return { browserPageId } + } + + async closeTab(browserPageId: string): Promise { + const win = this.windowsByPageId.get(browserPageId) + this.windowsByPageId.delete(browserPageId) + this.browserManager.unregisterGuest(browserPageId) + if (win && !win.isDestroyed()) { + win.destroy() + } + } + + getWebContentsId(browserPageId: string): number | null { + const win = this.windowsByPageId.get(browserPageId) + return win && !win.isDestroyed() ? win.webContents.id : null + } + + destroyAll(): void { + for (const [pageId, win] of this.windowsByPageId) { + this.browserManager.unregisterGuest(pageId) + if (!win.isDestroyed()) { + win.destroy() + } + } + this.windowsByPageId.clear() + } + + private async loadUrl(win: BrowserWindow, url: string): Promise { + const wc = win.webContents + await new Promise((resolve, reject) => { + let settled = false + const timer = setTimeout(() => { + if (settled) { + return + } + settled = true + cleanup() + // Why: about:blank and slow pages can resolve via timeout without a + // did-finish-load; treat that as success so the tab is still operable. + resolve() + }, LOAD_TIMEOUT_MS) + + const onFinish = (): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve() + } + const onFail = ( + _e: unknown, + errorCode: number, + errorDescription: string, + _validatedURL: string, + isMainFrame: boolean + ): void => { + // Why: subframe/iframe (e.g. ad/tracker) load failures also fire + // did-fail-load. Only the main frame failing means the page itself + // failed; ignore the rest or an otherwise-usable page gets rejected. + if (!isMainFrame) { + return + } + if (settled) { + return + } + settled = true + cleanup() + // Why: aborted loads (-3) happen on redirects/SPA navigations and are not + // real failures; the page is still usable. + if (errorCode === -3) { + resolve() + return + } + reject(new Error(`${errorDescription} (${errorCode})`)) + } + const cleanup = (): void => { + clearTimeout(timer) + wc.removeListener('did-finish-load', onFinish) + wc.removeListener('did-fail-load', onFail) + } + + wc.on('did-finish-load', onFinish) + wc.on('did-fail-load', onFail) + void wc.loadURL(url).catch(() => { + // loadURL rejects on aborted navigations; did-fail-load handles the rest. + }) + }) + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 891eb007161..8f53b770471 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -56,6 +56,7 @@ import { patchPackagedProcessPath, shouldInstallManagedHooks } from './startup/configure-process' +import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display' import { shouldSuppressDevEducation, suppressDevEducationForStore @@ -109,6 +110,7 @@ import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { EmulatorBridge } from './emulator/emulator-bridge' import { serveSimStateWatcher } from './emulator/serve-sim-state-watcher' import { browserManager } from './browser/browser-manager' +import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend' import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' import { setUnreadDockBadgeCount } from './dock/unread-badge' import { AutomationService } from './automations/service' @@ -164,6 +166,9 @@ let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null let runtime: OrcaRuntimeService | null = null let rateLimits: RateLimitService | null = null let runtimeRpc: OrcaRuntimeRpcServer | null = null +// Why: set during early startup; gates whether headless serve installs the +// offscreen browser backend (and thus advertises browser pane support). +let headlessBrowserDisplayAvailable = false let starNag: StarNagService | null = null let agentAwakeService: AgentAwakeService | null = null @@ -488,6 +493,10 @@ if (hasSingleInstanceLock) { }) configureElectronNetworkCompatibility() enableMainProcessGpuFeatures() + // Why: headless serve backs browser panes with offscreen BrowserWindows, which + // need an X display on Linux. Ensure one (Xvfb) before whenReady; the result + // gates whether the offscreen backend is installed so capability stays honest. + headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ isServeMode }) } ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { @@ -1629,6 +1638,13 @@ app.whenReady().then(async () => { (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), store ) + // Why: headless servers have no renderer to mount browser panes. + // Back them with main-process offscreen WebContents instead, so this host can + // own browser pages and advertise browser.headless.v1 — but only when a + // display is actually available (set up above), so the capability stays honest. + if (headlessBrowserDisplayAvailable) { + runtime.setOffscreenBrowserBackend(new OffscreenBrowserBackend(browserManager)) + } // Why: headless servers have no renderer graph publisher. Publish an // explicit empty graph so status clients see a ready server while // renderer-only operations still fail at their own window boundary. @@ -1714,6 +1730,9 @@ app.on('will-quit', (e) => { // Why: agent-browser daemon processes would otherwise linger after Orca quits, // holding ports and leaving stale session state on disk. runtime?.getAgentBrowserBridge()?.destroyAllSessions() + // Why: headless offscreen browser windows are main-process owned; tear them + // down explicitly on quit alongside the other browser/session shutdowns. + runtime?.getOffscreenBrowserBackend()?.destroyAll?.() const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() serveSimStateWatcher.stop() killAllPty() diff --git a/src/main/runtime/headless-tab-group-split-layout.test.ts b/src/main/runtime/headless-tab-group-split-layout.test.ts new file mode 100644 index 00000000000..604941bb446 --- /dev/null +++ b/src/main/runtime/headless-tab-group-split-layout.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest' +import { + buildHeadlessTabGroupMove, + buildHeadlessTabGroupSplit, + collectTabGroupLayoutGroupIds, + removeTabGroupLayoutLeaf +} from './headless-tab-group-split-layout' + +const group = (id: string, tabs: string[], activeTabId = tabs[0] ?? null) => ({ + id, + activeTabId, + tabOrder: tabs +}) + +describe('buildHeadlessTabGroupSplit', () => { + it('splits a tab into a new group to the right (horizontal, second)', () => { + const result = buildHeadlessTabGroupSplit({ + groups: [group('g1', ['a', 'b'])], + layout: { type: 'leaf', groupId: 'g1' }, + tabId: 'b', + targetGroupId: 'g1', + splitDirection: 'right', + newGroupId: 'g2' + }) + expect(result).not.toBeNull() + expect(result!.groups).toHaveLength(2) + expect(result!.groups.find((g) => g.id === 'g1')!.tabOrder).toEqual(['a']) + expect(result!.groups.find((g) => g.id === 'g2')!.tabOrder).toEqual(['b']) + expect(result!.layout).toEqual({ + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'g1' }, + second: { type: 'leaf', groupId: 'g2' }, + ratio: 0.5 + }) + }) + + it('places the new group first when splitting left', () => { + const result = buildHeadlessTabGroupSplit({ + groups: [group('g1', ['a', 'b'])], + layout: { type: 'leaf', groupId: 'g1' }, + tabId: 'b', + targetGroupId: 'g1', + splitDirection: 'left', + newGroupId: 'g2' + }) + expect(result!.layout).toMatchObject({ + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'g2' }, + second: { type: 'leaf', groupId: 'g1' } + }) + }) + + it('uses a vertical split for up/down', () => { + const down = buildHeadlessTabGroupSplit({ + groups: [group('g1', ['a', 'b'])], + layout: { type: 'leaf', groupId: 'g1' }, + tabId: 'b', + targetGroupId: 'g1', + splitDirection: 'down', + newGroupId: 'g2' + }) + expect(down!.layout).toMatchObject({ type: 'split', direction: 'vertical' }) + }) + + it('returns null when splitting the only tab off its own group (renderer no-op parity)', () => { + const result = buildHeadlessTabGroupSplit({ + groups: [group('g1', ['a'])], + layout: { type: 'leaf', groupId: 'g1' }, + tabId: 'a', + targetGroupId: 'g1', + splitDirection: 'right', + newGroupId: 'g2' + }) + expect(result).toBeNull() + }) + + it('returns null when the tab is not in any group', () => { + expect( + buildHeadlessTabGroupSplit({ + groups: [group('g1', ['a'])], + layout: { type: 'leaf', groupId: 'g1' }, + tabId: 'missing', + targetGroupId: 'g1', + splitDirection: 'right', + newGroupId: 'g2' + }) + ).toBeNull() + }) + + it('moves the active tab out and reassigns source active to the survivor', () => { + const result = buildHeadlessTabGroupSplit({ + groups: [group('g1', ['a', 'b'], 'b')], + layout: { type: 'leaf', groupId: 'g1' }, + tabId: 'b', + targetGroupId: 'g1', + splitDirection: 'right', + newGroupId: 'g2' + }) + expect(result!.groups.find((g) => g.id === 'g1')!.activeTabId).toBe('a') + expect(result!.groups.find((g) => g.id === 'g2')!.activeTabId).toBe('b') + }) + + it('collapses an emptied source group out of the layout when moving across groups', () => { + // g2 holds only 'b'; moving 'b' into a new split off g1 empties g2. + const result = buildHeadlessTabGroupSplit({ + groups: [group('g1', ['a']), group('g2', ['b'])], + layout: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'g1' }, + second: { type: 'leaf', groupId: 'g2' } + }, + tabId: 'b', + targetGroupId: 'g1', + splitDirection: 'right', + newGroupId: 'g3' + }) + const ids = result!.groups.map((g) => g.id).sort() + expect(ids).toEqual(['g1', 'g3']) + // g2 must not survive in the layout tree. + expect([...collectTabGroupLayoutGroupIds(result!.layout)].sort()).toEqual(['g1', 'g3']) + }) + + it('synthesizes a leaf layout when none exists yet', () => { + const result = buildHeadlessTabGroupSplit({ + groups: [group('g1', ['a', 'b'])], + layout: null, + tabId: 'b', + targetGroupId: 'g1', + splitDirection: 'right', + newGroupId: 'g2' + }) + expect(result!.layout).toMatchObject({ type: 'split' }) + expect([...collectTabGroupLayoutGroupIds(result!.layout)].sort()).toEqual(['g1', 'g2']) + }) +}) + +describe('buildHeadlessTabGroupMove', () => { + const twoGroupLayout = { + type: 'split' as const, + direction: 'horizontal' as const, + first: { type: 'leaf' as const, groupId: 'g1' }, + second: { type: 'leaf' as const, groupId: 'g2' } + } + + it('moves a tab into an existing group at the given index', () => { + const result = buildHeadlessTabGroupMove({ + groups: [group('g1', ['a', 'b']), group('g2', ['c'])], + layout: twoGroupLayout, + tabId: 'b', + targetGroupId: 'g2', + index: 0 + }) + expect(result!.groups.find((g) => g.id === 'g1')!.tabOrder).toEqual(['a']) + expect(result!.groups.find((g) => g.id === 'g2')!.tabOrder).toEqual(['b', 'c']) + expect(result!.groups.find((g) => g.id === 'g2')!.activeTabId).toBe('b') + }) + + it('collapses the source group out of the layout when the move empties it', () => { + const result = buildHeadlessTabGroupMove({ + groups: [group('g1', ['a']), group('g2', ['b'])], + layout: twoGroupLayout, + tabId: 'a', + targetGroupId: 'g2' + }) + expect(result!.groups.map((g) => g.id)).toEqual(['g2']) + expect(result!.layout).toEqual({ type: 'leaf', groupId: 'g2' }) + }) + + it('returns null for a same-group move (renderer no-op)', () => { + expect( + buildHeadlessTabGroupMove({ + groups: [group('g1', ['a', 'b'])], + layout: { type: 'leaf', groupId: 'g1' }, + tabId: 'b', + targetGroupId: 'g1' + }) + ).toBeNull() + }) + + it('returns null when the target group does not exist', () => { + expect( + buildHeadlessTabGroupMove({ + groups: [group('g1', ['a', 'b'])], + layout: { type: 'leaf', groupId: 'g1' }, + tabId: 'b', + targetGroupId: 'missing' + }) + ).toBeNull() + }) +}) + +describe('removeTabGroupLayoutLeaf', () => { + it('collapses a split into its sibling when a leaf is removed', () => { + expect( + removeTabGroupLayoutLeaf( + { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'g1' }, + second: { type: 'leaf', groupId: 'g2' } + }, + 'g2' + ) + ).toEqual({ type: 'leaf', groupId: 'g1' }) + }) + + it('returns null when the last leaf is removed', () => { + expect(removeTabGroupLayoutLeaf({ type: 'leaf', groupId: 'g1' }, 'g1')).toBeNull() + }) +}) diff --git a/src/main/runtime/headless-tab-group-split-layout.ts b/src/main/runtime/headless-tab-group-split-layout.ts new file mode 100644 index 00000000000..91167f0bcdf --- /dev/null +++ b/src/main/runtime/headless-tab-group-split-layout.ts @@ -0,0 +1,218 @@ +import type { RuntimeMobileSessionTabGroup } from '../../shared/runtime-types' +import type { TabGroupLayoutNode } from '../../shared/types' + +/** + * Headless ("Orca server") tab-GROUP split operations (distinct from terminal + * PANE splits inside one tab). The headless host historically coalesced every + * tab into a single group, so a client drag-to-split-group was lost on the next + * snapshot. These pure helpers let the host model + persist a real multi-group + * layout, mirroring the renderer's buildSplitNode/replaceLeaf semantics so host + * and client agree on the tree. + */ + +type SplitDirection = 'left' | 'right' | 'up' | 'down' + +function buildSplitNode( + existingGroupId: string, + newGroupId: string, + direction: 'horizontal' | 'vertical', + position: 'first' | 'second' +): TabGroupLayoutNode { + const existingLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: existingGroupId } + const newLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: newGroupId } + return { + type: 'split', + direction, + first: position === 'first' ? newLeaf : existingLeaf, + second: position === 'second' ? newLeaf : existingLeaf, + ratio: 0.5 + } +} + +function replaceLeaf( + root: TabGroupLayoutNode, + targetGroupId: string, + replacement: TabGroupLayoutNode +): TabGroupLayoutNode { + if (root.type === 'leaf') { + return root.groupId === targetGroupId ? replacement : root + } + return { + ...root, + first: replaceLeaf(root.first, targetGroupId, replacement), + second: replaceLeaf(root.second, targetGroupId, replacement) + } +} + +/** Collect every groupId referenced by a layout tree. */ +export function collectTabGroupLayoutGroupIds( + node: TabGroupLayoutNode | null | undefined, + groupIds = new Set() +): Set { + if (!node) { + return groupIds + } + if (node.type === 'leaf') { + groupIds.add(node.groupId) + return groupIds + } + collectTabGroupLayoutGroupIds(node.first, groupIds) + collectTabGroupLayoutGroupIds(node.second, groupIds) + return groupIds +} + +/** Remove a leaf from the tree, collapsing the parent split into the sibling. */ +export function removeTabGroupLayoutLeaf( + root: TabGroupLayoutNode | null | undefined, + groupId: string +): TabGroupLayoutNode | null { + if (!root) { + return null + } + if (root.type === 'leaf') { + return root.groupId === groupId ? null : root + } + const first = removeTabGroupLayoutLeaf(root.first, groupId) + const second = removeTabGroupLayoutLeaf(root.second, groupId) + if (!first) { + return second + } + if (!second) { + return first + } + return { ...root, first, second } +} + +export type HeadlessTabGroupMoveResult = { + groups: RuntimeMobileSessionTabGroup[] + layout: TabGroupLayoutNode | null +} + +/** + * Move `tabId` into an EXISTING `targetGroupId` (a non-split drop), mirroring the + * renderer's move-to-group path. Inserts at `index` (clamped), removes the tab + * from its source group, and drops a source group emptied by the move (collapsing + * it out of the layout). Returns null when the tab/target is missing or it's a + * same-group no-op. + */ +export function buildHeadlessTabGroupMove(args: { + groups: readonly RuntimeMobileSessionTabGroup[] + layout: TabGroupLayoutNode | null | undefined + tabId: string + targetGroupId: string + index?: number +}): HeadlessTabGroupMoveResult | null { + const sourceGroup = args.groups.find((group) => group.tabOrder.includes(args.tabId)) + const targetGroup = args.groups.find((group) => group.id === args.targetGroupId) + if (!sourceGroup || !targetGroup) { + return null + } + if (sourceGroup.id === args.targetGroupId) { + return null + } + + let groups: RuntimeMobileSessionTabGroup[] = args.groups.map((group) => { + if (group.id === sourceGroup.id) { + const tabOrder = group.tabOrder.filter((id) => id !== args.tabId) + return { + ...group, + tabOrder, + activeTabId: group.activeTabId === args.tabId ? (tabOrder[0] ?? null) : group.activeTabId + } + } + if (group.id === args.targetGroupId) { + const tabOrder = group.tabOrder.filter((id) => id !== args.tabId) + const at = Math.max(0, Math.min(args.index ?? tabOrder.length, tabOrder.length)) + tabOrder.splice(at, 0, args.tabId) + return { ...group, tabOrder, activeTabId: args.tabId } + } + return group + }) + + groups = groups.filter((group) => group.tabOrder.length > 0) + const liveGroupIds = new Set(groups.map((group) => group.id)) + let layout: TabGroupLayoutNode | null = args.layout ?? null + for (const groupId of collectTabGroupLayoutGroupIds(args.layout)) { + if (!liveGroupIds.has(groupId)) { + layout = removeTabGroupLayoutLeaf(layout, groupId) + } + } + return { groups, layout } +} + +export type HeadlessTabGroupSplitResult = { + groups: RuntimeMobileSessionTabGroup[] + layout: TabGroupLayoutNode + newGroupId: string +} + +/** + * Move `tabId` out of its current group into a NEW group split off from + * `targetGroupId` in `splitDirection`, mirroring the renderer's dropUnifiedTab + * split path. Returns the next groups + group layout tree. + * + * Returns null when the move can't apply (tab/group missing, or it would split + * the only tab off its own group — a renderer-side no-op). + */ +export function buildHeadlessTabGroupSplit(args: { + groups: readonly RuntimeMobileSessionTabGroup[] + layout: TabGroupLayoutNode | null | undefined + tabId: string + targetGroupId: string + splitDirection: SplitDirection + newGroupId: string +}): HeadlessTabGroupSplitResult | null { + const sourceGroup = args.groups.find((group) => group.tabOrder.includes(args.tabId)) + if (!sourceGroup) { + return null + } + // Splitting the last tab off its own group would create a sibling only to + // immediately collapse the empty source — a no-op the renderer skips too. + if (sourceGroup.id === args.targetGroupId && sourceGroup.tabOrder.length <= 1) { + return null + } + + const direction = + args.splitDirection === 'left' || args.splitDirection === 'right' ? 'horizontal' : 'vertical' + const position = + args.splitDirection === 'left' || args.splitDirection === 'up' ? 'first' : 'second' + + const baseLayout: TabGroupLayoutNode = args.layout ?? { + type: 'leaf', + groupId: args.targetGroupId + } + const layout = replaceLeaf( + baseLayout, + args.targetGroupId, + buildSplitNode(args.targetGroupId, args.newGroupId, direction, position) + ) + + const sourceOrder = sourceGroup.tabOrder.filter((id) => id !== args.tabId) + let groups: RuntimeMobileSessionTabGroup[] = args.groups.map((group) => { + if (group.id === sourceGroup.id) { + return { + ...group, + tabOrder: sourceOrder, + activeTabId: + group.activeTabId === args.tabId ? (sourceOrder[0] ?? null) : group.activeTabId + } + } + return group + }) + groups.push({ id: args.newGroupId, activeTabId: args.tabId, tabOrder: [args.tabId] }) + // Drop any group emptied by the move and collapse it out of the layout. + groups = groups.filter((group) => group.tabOrder.length > 0) + const liveGroupIds = new Set(groups.map((group) => group.id)) + let prunedLayout: TabGroupLayoutNode | null = layout + for (const groupId of collectTabGroupLayoutGroupIds(layout)) { + if (!liveGroupIds.has(groupId)) { + prunedLayout = removeTabGroupLayoutLeaf(prunedLayout, groupId) + } + } + + return { + groups, + layout: prunedLayout ?? { type: 'leaf', groupId: args.newGroupId }, + newGroupId: args.newGroupId + } +} diff --git a/src/main/runtime/headless-terminal-split-layout.test.ts b/src/main/runtime/headless-terminal-split-layout.test.ts new file mode 100644 index 00000000000..000434ee3d7 --- /dev/null +++ b/src/main/runtime/headless-terminal-split-layout.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../shared/types' +import { + buildHeadlessTerminalSplitLayout, + countTerminalLayoutLeaves +} from './headless-terminal-split-layout' + +describe('buildHeadlessTerminalSplitLayout (headless split persistence)', () => { + it('splits a single-leaf tab into a 2-leaf split tree', () => { + const existing: TerminalLayoutSnapshot = { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'leaf-a', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-a': 'pty-a' } + } + const next = buildHeadlessTerminalSplitLayout(existing, { + leafId: 'leaf-b', + ptyId: 'pty-b', + splitFromLeafId: 'leaf-a', + direction: 'vertical' + }) + expect(countTerminalLayoutLeaves(next.root)).toBe(2) + expect(next.root).toEqual({ + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + }) + expect(next.activeLeafId).toBe('leaf-b') + expect(next.ptyIdsByLeafId).toEqual({ 'leaf-a': 'pty-a', 'leaf-b': 'pty-b' }) + }) + + it('splits a nested leaf inside an existing split (split-of-a-split)', () => { + const existing: TerminalLayoutSnapshot = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + }, + activeLeafId: 'leaf-b', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-a': 'pty-a', 'leaf-b': 'pty-b' } + } + const next = buildHeadlessTerminalSplitLayout(existing, { + leafId: 'leaf-c', + ptyId: 'pty-c', + splitFromLeafId: 'leaf-b', + direction: 'horizontal' + }) + // 3 leaves total; the split happened at leaf-b, leaf-a untouched. + expect(countTerminalLayoutLeaves(next.root)).toBe(3) + expect(next.root).toEqual({ + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-b' }, + second: { type: 'leaf', leafId: 'leaf-c' } + } + }) + expect(next.ptyIdsByLeafId).toEqual({ + 'leaf-a': 'pty-a', + 'leaf-b': 'pty-b', + 'leaf-c': 'pty-c' + }) + }) + + it('synthesizes a split when there is no existing persisted layout', () => { + const next = buildHeadlessTerminalSplitLayout(undefined, { + leafId: 'leaf-b', + ptyId: 'pty-b', + splitFromLeafId: 'leaf-a', + direction: 'horizontal' + }) + expect(countTerminalLayoutLeaves(next.root)).toBe(2) + expect(next.root).toEqual({ + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + }) + }) + + it('does not collapse — the persisted layout keeps both leaves (regression guard)', () => { + // Why: the reported bug was the split collapsing back to one pane. After a + // split, a rebuild reads this persisted layout, so it MUST stay multi-leaf. + const afterSplit = buildHeadlessTerminalSplitLayout( + { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'leaf-a', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-a': 'pty-a' } + }, + { leafId: 'leaf-b', ptyId: 'pty-b', splitFromLeafId: 'leaf-a', direction: 'vertical' } + ) + expect(countTerminalLayoutLeaves(afterSplit.root)).toBeGreaterThan(1) + }) +}) diff --git a/src/main/runtime/headless-terminal-split-layout.ts b/src/main/runtime/headless-terminal-split-layout.ts new file mode 100644 index 00000000000..c3947334f77 --- /dev/null +++ b/src/main/runtime/headless-terminal-split-layout.ts @@ -0,0 +1,59 @@ +import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../shared/types' + +/** + * Insert a newly split-off leaf into a terminal tab's persisted layout tree. + * + * Why: a headless ("Orca server") split only updated the live session snapshot, + * never the persisted workspace-session layout, so a later snapshot rebuild + * re-derived from the stale single-leaf layout and collapsed the split. This + * builds the durable post-split layout so the split survives rebuilds. + */ +export function buildHeadlessTerminalSplitLayout( + existing: TerminalLayoutSnapshot | undefined, + args: { + leafId: string + ptyId: string + splitFromLeafId: string + direction: 'horizontal' | 'vertical' + } +): TerminalLayoutSnapshot { + const existingRoot: TerminalPaneLayoutNode = existing?.root ?? { + type: 'leaf', + leafId: args.splitFromLeafId + } + const insertSplit = (node: TerminalPaneLayoutNode): TerminalPaneLayoutNode => { + if (node.type === 'leaf') { + if (node.leafId !== args.splitFromLeafId) { + return node + } + return { + type: 'split', + direction: args.direction, + first: node, + second: { type: 'leaf', leafId: args.leafId } + } + } + return { ...node, first: insertSplit(node.first), second: insertSplit(node.second) } + } + return { + ...existing, + root: insertSplit(existingRoot), + activeLeafId: args.leafId, + expandedLeafId: existing?.expandedLeafId ?? null, + ptyIdsByLeafId: { + ...existing?.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } +} + +/** Count the leaves in a layout tree (a split has ≥2; a single pane has 1). */ +export function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number { + if (!node) { + return 0 + } + if (node.type === 'leaf') { + return 1 + } + return countTerminalLayoutLeaves(node.first) + countTerminalLayoutLeaves(node.second) +} diff --git a/src/main/runtime/orca-runtime-browser.test.ts b/src/main/runtime/orca-runtime-browser.test.ts index a8ce8b56ce8..9197c1694ab 100644 --- a/src/main/runtime/orca-runtime-browser.test.ts +++ b/src/main/runtime/orca-runtime-browser.test.ts @@ -67,6 +67,7 @@ function createHost(overrides: Partial = {}): Runtime resolveWorktreeSelector: async (selector) => ({ id: selector.replace(/^id:/, '') }), getAuthoritativeWindow: vi.fn(), getAvailableAuthoritativeWindow: vi.fn(() => null), + getOffscreenBrowserBackend: vi.fn(() => null), ...overrides, getAgentBrowserBridge: () => bridge } as unknown as RuntimeBrowserCommandHost @@ -378,3 +379,130 @@ describe('RuntimeBrowserCommands browser screencast', () => { expect(secondStop).toHaveBeenCalledTimes(1) }, 10_000) }) + +describe('RuntimeBrowserCommands headless offscreen routing', () => { + beforeEach(() => { + ipcMainOnMock.mockReset() + webContentsFromIdMock.mockReset() + waitForTabRegistrationMock.mockReset() + waitForTabRegistrationMock.mockResolvedValue(undefined) + waitForWorktreeTabRegistrationMock.mockReset() + waitForWorktreeTabRegistrationMock.mockResolvedValue(undefined) + }) + + it('routes tab creation to the offscreen backend when no renderer window exists', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const createTab = vi.fn(async () => ({ browserPageId: 'page-offscreen' })) + const setActiveTab = vi.fn() + const bridge = { + getRegisteredTabs: vi.fn(() => new Map([['page-offscreen', 202]])), + setActiveTab + } as unknown as AgentBrowserBridge + const commands = new RuntimeBrowserCommands( + createHost({ + getAgentBrowserBridge: () => bridge, + getAvailableAuthoritativeWindow: vi.fn(() => null), + getOffscreenBrowserBackend: vi.fn(() => ({ createTab, closeTab: vi.fn() })) + }) + ) + + await expect( + commands.browserTabCreate({ worktree: 'id:wt-1', url: 'https://example.com' }) + ).resolves.toEqual({ browserPageId: 'page-offscreen' }) + + expect(createTab).toHaveBeenCalledWith({ + url: 'https://example.com', + worktreeId: 'wt-1', + profileId: undefined + }) + // No renderer round-trip in headless mode. + expect(waitForTabRegistrationMock).not.toHaveBeenCalled() + expect(setActiveTab).toHaveBeenCalledWith(202, 'wt-1') + }) + + it('rejects tab creation when neither a renderer nor an offscreen backend is available', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const commands = new RuntimeBrowserCommands( + createHost({ + getAvailableAuthoritativeWindow: vi.fn(() => null), + getOffscreenBrowserBackend: vi.fn(() => null) + }) + ) + + await expect(commands.browserTabCreate({ url: 'about:blank' })).rejects.toThrow( + /does not support browser panes/ + ) + }) + + it('closes a headless tab via the offscreen backend without a renderer round-trip', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false }) + const closeTab = vi.fn(async () => {}) + const bridge = { + getRegisteredTabs: vi.fn(() => new Map([['page-offscreen', 202]])), + getActivePageId: vi.fn(() => 'page-offscreen'), + getActiveWebContentsId: vi.fn(() => 202) + } as unknown as AgentBrowserBridge + const commands = new RuntimeBrowserCommands( + createHost({ + getAgentBrowserBridge: () => bridge, + getAvailableAuthoritativeWindow: vi.fn(() => null), + getOffscreenBrowserBackend: vi.fn(() => ({ createTab: vi.fn(), closeTab })) + }) + ) + + await expect( + commands.browserTabClose({ worktree: 'id:wt-1', page: 'page-offscreen' }) + ).resolves.toEqual({ closed: true }) + + expect(closeTab).toHaveBeenCalledWith('page-offscreen') + // The renderer close IPC must not be used in headless mode. + expect(ipcMainOnMock).not.toHaveBeenCalledWith('browser:tabCloseReply', expect.anything()) + }) + + it('closes the active headless tab on an implicit close', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false }) + const closeTab = vi.fn(async () => {}) + const bridge = { + getRegisteredTabs: vi.fn(() => new Map([['page-active', 303]])), + getActivePageId: vi.fn(() => 'page-active'), + getActiveWebContentsId: vi.fn(() => 303) + } as unknown as AgentBrowserBridge + const commands = new RuntimeBrowserCommands( + createHost({ + getAgentBrowserBridge: () => bridge, + getAvailableAuthoritativeWindow: vi.fn(() => null), + getOffscreenBrowserBackend: vi.fn(() => ({ createTab: vi.fn(), closeTab })) + }) + ) + + // No --page / --index: resolves the active page rather than no-op succeeding. + await expect(commands.browserTabClose({ worktree: 'id:wt-1' })).resolves.toEqual({ + closed: true + }) + expect(closeTab).toHaveBeenCalledWith('page-active') + }) + + it('reports not-closed (no false success) when no headless tab can be resolved', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const closeTab = vi.fn(async () => {}) + const bridge = { + getRegisteredTabs: vi.fn(() => new Map()), + getActivePageId: vi.fn(() => null), + getActiveWebContentsId: vi.fn(() => null) + } as unknown as AgentBrowserBridge + const commands = new RuntimeBrowserCommands( + createHost({ + getAgentBrowserBridge: () => bridge, + getAvailableAuthoritativeWindow: vi.fn(() => null), + getOffscreenBrowserBackend: vi.fn(() => ({ createTab: vi.fn(), closeTab })) + }) + ) + + await expect(commands.browserTabClose({ worktree: 'id:wt-1' })).resolves.toEqual({ + closed: false + }) + expect(closeTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index 0f6c6e3d893..1f04a6543f9 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -50,6 +50,7 @@ import type { BrowserWaitResult } from '../../shared/runtime-types' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import type { BrowserBackend } from '../browser/browser-backend' import { browserManager } from '../browser/browser-manager' import { BrowserError } from '../browser/cdp-bridge' import { @@ -142,6 +143,20 @@ export type RuntimeBrowserCommandHost = { resolveWorktreeSelector(selector: string): Promise<{ id: string }> getAuthoritativeWindow(): BrowserWindow getAvailableAuthoritativeWindow(): BrowserWindow | null + // Why: headless serve has no renderer window; browser pages are backed by a + // main-process offscreen backend instead. Null when offscreen browsing is + // unavailable (e.g. environment can't support it), which keeps capability + // reporting honest. + getOffscreenBrowserBackend(): BrowserBackend | null + // Why: the session-tab snapshot is the source of truth for which tab is + // focused. A headless browser create must mark itself active there so paired + // clients keep focus on the new tab instead of the reconcile snapping back to + // a terminal (whose activeTabType the snapshot still reports). + markHeadlessBrowserSessionTabActive?( + worktreeId: string | undefined, + browserPageId: string, + targetGroupId?: string + ): void } export class RuntimeBrowserCommands { @@ -1314,15 +1329,27 @@ export class RuntimeBrowserCommands { profileId?: string waitForRegistration?: boolean activate?: boolean + targetGroupId?: string }): Promise<{ browserPageId: string }> { const url = params.url ?? 'about:blank' const worktreeId = params.worktree ? (await this.host.resolveWorktreeSelector(params.worktree)).id : undefined + // Why: a desktop renderer mounts a ; a headless serve has none and + // backs the page with a main-process offscreen WebContents instead. Both + // register into BrowserManager so all downstream commands resolve uniformly. if (!this.host.getAvailableAuthoritativeWindow()) { - throw new BrowserError( - 'browser_error', - 'Browser tab creation requires a desktop renderer; headless orca serve does not support browser panes yet.' + const offscreen = this.host.getOffscreenBrowserBackend() + if (!offscreen) { + throw new BrowserError('browser_error', 'This host does not support browser panes.') + } + return this.createBrowserTabOffscreen( + offscreen, + url, + worktreeId, + params.profileId, + params.activate, + params.targetGroupId ) } const { browserPageId } = await this.createBrowserTabInRenderer( @@ -1638,6 +1665,23 @@ export class RuntimeBrowserCommands { } } + // Why: headless serve owns its pages via the offscreen backend, with no + // renderer to ask. Destroy the offscreen page directly when that backend is + // the one serving this host (no renderer window). + const offscreen = this.host.getAvailableAuthoritativeWindow() + ? null + : this.host.getOffscreenBrowserBackend() + if (offscreen) { + // Why: for implicit close (no --page/--index) resolve the active page like + // the renderer path does, so we don't report success while closing nothing. + const resolvedTabId = tabId ?? bridge.getActivePageId(worktreeId) + if (!resolvedTabId) { + return { closed: false } + } + await offscreen.closeTab(resolvedTabId) + return { closed: true } + } + const win = this.host.getAuthoritativeWindow() const requestId = randomUUID() await new Promise((resolve, reject) => { @@ -1706,6 +1750,35 @@ export class RuntimeBrowserCommands { return this.enrichBrowserTabInfo(tab) } + // Why: headless serve path. The offscreen backend registers the page + // synchronously, so there is no webview-mount wait. The page already loaded the + // URL during createTab, so we only sync the bridge's active tab and notify the + // (absent) renderer is skipped — nav state is read from the live WebContents. + private async createBrowserTabOffscreen( + offscreen: BrowserBackend, + url: string, + worktreeId?: string, + profileId?: string, + activate?: boolean, + targetGroupId?: string + ): Promise<{ browserPageId: string }> { + const { browserPageId } = await offscreen.createTab({ url, worktreeId, profileId }) + const bridge = this.host.getAgentBrowserBridge() + const wcId = bridge?.getRegisteredTabs(worktreeId).get(browserPageId) + if (bridge && wcId != null) { + bridge.setActiveTab(wcId, worktreeId) + } + // Why: only a user-initiated create (activate:true, e.g. the UI or a mobile + // HTML-link tap) should steal focus by marking the tab active in the session + // snapshot. Background/agent creates (CLI `tab create`, automation) must NOT, + // or they'd yank a connected client/mobile to the new tab. Mirrors the + // renderer path, which forwards `activate` and never force-focuses otherwise. + if (activate === true) { + this.host.markHeadlessBrowserSessionTabActive?.(worktreeId, browserPageId, targetGroupId) + } + return { browserPageId } + } + private async createBrowserTabInRenderer( url: string, worktreeId?: string, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 77705cba693..9cf2c6c995e 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1223,6 +1223,45 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().capabilities).toContain('browser.screencast.v1') }) + it('advertises headless browser capability when an offscreen backend backs a windowless host', () => { + const runtime = createRuntime() + runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) + + const capabilities = runtime.getStatus().capabilities + // Headless serve can still create/stream pages, so screencast is supported... + expect(capabilities).toContain('browser.screencast.v1') + // ...and the headless marker tells clients not to fall back to a local tab. + expect(capabilities).toContain('browser.headless.v1') + }) + + it('does not advertise headless browser capability when a renderer window exists', () => { + const runtime = createRuntime() + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) + + expect(runtime.getStatus().capabilities).not.toContain('browser.headless.v1') + }) + + it('closes a worktree’s offscreen browser pages when its metadata is removed (leak fix)', () => { + const runtime = createRuntime() + const closeTab = vi.fn().mockResolvedValue(undefined) + runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab }) + runtime.setAgentBrowserBridge({ + tabList: vi.fn((worktreeId: string) => + worktreeId === TEST_WORKTREE_ID + ? { tabs: [{ browserPageId: 'page-a' }, { browserPageId: 'page-b' }] } + : { tabs: [] } + ) + } as never) + + runtime['removeWorktreeMetadataAndHistory'](store as never, TEST_WORKTREE_ID) + + expect(closeTab).toHaveBeenCalledWith('page-a') + expect(closeTab).toHaveBeenCalledWith('page-b') + expect(closeTab).toHaveBeenCalledTimes(2) + }) + it('claims the first window as authoritative and ignores later windows', () => { const runtime = createRuntime() @@ -5640,6 +5679,20 @@ describe('OrcaRuntimeService', () => { splitFromLeafId: sourceLeafId, splitDirection: 'vertical' }) + + // Why: regression for "Split Right renders as a down split". The published + // mobile-session snapshot must carry the requested direction on EVERY + // sibling surface — the client picks one surface's parentLayout to render + // the whole tab, so a stale single-leaf sibling would flip the orientation. + const publishedTabs = runtime['mobileSessionTabsByWorktree'].get(TEST_WORKTREE_ID)!.tabs + const siblingSurfaces = publishedTabs.filter( + (tab): tab is Extract => + tab.type === 'terminal' && tab.parentTabId === sourceEnv.ORCA_TAB_ID + ) + expect(siblingSurfaces.length).toBe(2) + for (const surface of siblingSurfaces) { + expect(surface.parentLayout?.root).toMatchObject({ type: 'split', direction: 'vertical' }) + } }) it('splits folder workspace pty-backed terminal sessions with folder cwd and env', async () => { @@ -10699,6 +10752,354 @@ describe('OrcaRuntimeService', () => { expect(listed.activeTabId).toBe('host-tab::pane:2') }) + it('keeps a headless tab-group split alive when a new tab is created', async () => { + // Regression: drag-to-split-group was a client-only change the headless host + // rejected (renderer_unavailable), so creating a new tab coalesced the + // groups back into one. The host must model + persist the split. + let ptyCounter = 0 + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: `split-group-pty-${++ptyCounter}` })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + const first = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { activate: true }) + const second = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { activate: true }) + + const beforeSplit = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(beforeSplit.tabGroups).toHaveLength(1) + const sourceGroupId = beforeSplit.tabGroups![0]!.id + const secondHostTabId = second.tabId! + + await runtime.moveMobileSessionTab(`id:${TEST_WORKTREE_ID}`, { + kind: 'split', + tabId: secondHostTabId, + targetGroupId: sourceGroupId, + splitDirection: 'right' + }) + + const afterSplit = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(afterSplit.tabGroups).toHaveLength(2) + expect(afterSplit.tabGroupLayout).toMatchObject({ type: 'split', direction: 'horizontal' }) + + // The actual bug: creating a new tab must NOT collapse the split. + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { activate: true }) + + const afterNewTab = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(afterNewTab.tabGroups).toHaveLength(2) + expect(afterNewTab.tabGroupLayout).toMatchObject({ type: 'split' }) + // The split-off group keeps exactly its one tab; the new tab joins the other. + const splitOffGroup = afterNewTab.tabGroups!.find((group) => group.id !== sourceGroupId)! + expect(splitOffGroup.tabOrder).toEqual([secondHostTabId]) + expect(first.tabId).toBeTruthy() + + // Regression (#2): reordering one group must not delete the other group. + await runtime.moveMobileSessionTab(`id:${TEST_WORKTREE_ID}`, { + kind: 'reorder', + tabId: secondHostTabId, + targetGroupId: splitOffGroup.id, + tabOrder: [secondHostTabId] + }) + const afterReorder = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(afterReorder.tabGroups).toHaveLength(2) + }) + + it('restores a persisted multi-group split on a cold headless rehydrate', async () => { + // Regression (#1): hydrate must read back session.tabGroups/tabGroupLayouts, + // or a server restart coalesces the user's split into one group. + const session = makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: 'persisted-pty', + worktreeId: TEST_WORKTREE_ID, + title: 'Left', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + { + id: 'host-tab-2', + ptyId: 'persisted-pty-2', + worktreeId: TEST_WORKTREE_ID, + title: 'Right', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 2 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: 'persisted-pty' }), + 'host-tab-2': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: 'persisted-pty-2' }) + }, + tabGroups: { + [TEST_WORKTREE_ID]: [ + { id: 'group-left', worktreeId: TEST_WORKTREE_ID, activeTabId: 'host-tab', tabOrder: ['host-tab'] }, + { id: 'group-right', worktreeId: TEST_WORKTREE_ID, activeTabId: 'host-tab-2', tabOrder: ['host-tab-2'] } + ] + }, + tabGroupLayouts: { + [TEST_WORKTREE_ID]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-left' }, + second: { type: 'leaf', groupId: 'group-right' } + } + } + }) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + runtime['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'](TEST_WORKTREE_ID) + const rehydrated = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(rehydrated.tabGroups).toHaveLength(2) + expect(rehydrated.tabGroupLayout).toMatchObject({ type: 'split', direction: 'horizontal' }) + // Each persisted group keeps its own tab — no coalescing. + const left = rehydrated.tabGroups!.find((g) => g.id === 'group-left')! + const right = rehydrated.tabGroups!.find((g) => g.id === 'group-right')! + expect(left.tabOrder).toEqual(['host-tab']) + expect(right.tabOrder).toEqual(['host-tab-2']) + }) + + it('persists a headless terminal rename so it survives a cold rehydrate', async () => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'rename-pty' })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + // Bind a live pty to the persisted 'host-tab' so rename resolves by handle. + const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + activate: true + }) + + await expect(runtime.renameTerminal(created.handle, 'My Title')).resolves.toMatchObject({ + title: 'My Title' + }) + + // customTitle must be persisted to the workspace session (not just live pty). + const persistedTab = getSession().tabsByWorktree[TEST_WORKTREE_ID]!.find( + (tab) => tab.id === 'host-tab' + )! + expect(persistedTab.customTitle).toBe('My Title') + + // A cold rehydrate keeps the renamed title. + runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID) + runtime['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'](TEST_WORKTREE_ID) + const rehydrated = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const renamed = rehydrated.tabs.find( + (tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab' + ) + expect(renamed?.title).toBe('My Title') + }) + + it('persists a headless pane layout (ratio/expand) so it survives a cold rehydrate', async () => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.updateMobileSessionPaneLayout(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: HEADLESS_LEAF_ID }, + second: { type: 'leaf', leafId: 'leaf-2' }, + ratio: 0.7 + }, + expandedLeafId: null, + titlesByLeafId: { [HEADLESS_LEAF_ID]: 'Pane A' } + }) + + const persisted = getSession().terminalLayoutsByTabId['host-tab']! + expect(persisted.root).toMatchObject({ type: 'split', direction: 'vertical', ratio: 0.7 }) + expect(persisted.titlesByLeafId).toMatchObject({ [HEADLESS_LEAF_ID]: 'Pane A' }) + // Host-owned pty bindings must be preserved through the structural update. + expect(persisted.ptyIdsByLeafId).toMatchObject({ [HEADLESS_LEAF_ID]: 'persisted-pty' }) + + runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID) + runtime['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'](TEST_WORKTREE_ID) + const rehydrated = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const surface = rehydrated.tabs.find( + (tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab' + ) + expect(surface?.type === 'terminal' && surface.parentLayout?.root).toMatchObject({ + type: 'split', + ratio: 0.7 + }) + }) + + it('persists headless tab color + pin and surfaces them through a cold rehydrate', async () => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.setMobileSessionTabProps(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + color: '#ff8800', + isPinned: true + }) + + const persisted = getSession().tabsByWorktree[TEST_WORKTREE_ID]!.find( + (tab) => tab.id === 'host-tab' + )! + expect(persisted.color).toBe('#ff8800') + expect(persisted.isPinned).toBe(true) + + runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID) + runtime['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'](TEST_WORKTREE_ID) + const rehydrated = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const surface = rehydrated.tabs.find( + (tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab' + ) + expect(surface?.type === 'terminal' && surface.color).toBe('#ff8800') + expect(surface?.type === 'terminal' && surface.isPinned).toBe(true) + }) + + it('still persists tab props in serve mode after syncWindowGraph(0) (gate does not fire)', async () => { + // Why: the renderer-authoritative gate uses getAvailableAuthoritativeWindow, + // and serve startup calls syncWindowGraph(0,...) which sets authoritativeWindowId=0. + // BrowserWindow.fromId(0) is null, so the gate must NOT fire in serve mode. + const session = makeWorkspaceSessionWithHeadlessTerminal() + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await runtime.setMobileSessionTabProps(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + isPinned: true + }) + + expect( + getSession().tabsByWorktree[TEST_WORKTREE_ID]!.find((tab) => tab.id === 'host-tab')!.isPinned + ).toBe(true) + }) + + it('moves a headless tab into an existing group without renderer_unavailable', async () => { + let ptyCounter = 0 + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: `move-group-pty-${++ptyCounter}` })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { activate: true }) + const second = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { activate: true }) + const before = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const sourceGroupId = before.tabGroups![0]!.id + const secondHostTabId = second.tabId! + + // Split into 2 groups, then move the tab back into the source group. + await runtime.moveMobileSessionTab(`id:${TEST_WORKTREE_ID}`, { + kind: 'split', + tabId: secondHostTabId, + targetGroupId: sourceGroupId, + splitDirection: 'right' + }) + const split = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(split.tabGroups).toHaveLength(2) + + await expect( + runtime.moveMobileSessionTab(`id:${TEST_WORKTREE_ID}`, { + kind: 'move-to-group', + tabId: secondHostTabId, + targetGroupId: sourceGroupId + }) + ).resolves.toEqual({ moved: true }) + + const merged = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + // Moving the only tab back collapses the split to a single group. + expect(merged.tabGroups).toHaveLength(1) + expect(merged.tabGroups![0]!.tabOrder).toContain(secondHostTabId) + }) + + it('creates a new headless terminal in the targeted split group, not the active one', async () => { + // Regression: a per-group "+" passes targetGroupId, but the headless create + // ignored it and funneled every new tab into the active group. + let ptyCounter = 0 + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: `target-group-pty-${++ptyCounter}` })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + // Why: createMobileSessionTerminal asserts the graph is ready; serve mode + // marks it ready via syncWindowGraph(0,...) (windowId 0 ≠ a real renderer). + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { activate: true }) + const second = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { activate: true }) + const before = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const leftGroupId = before.tabGroups![0]!.id + + // Split the 2nd tab into a new right group; the new group becomes active. + await runtime.moveMobileSessionTab(`id:${TEST_WORKTREE_ID}`, { + kind: 'split', + tabId: second.tabId!, + targetGroupId: leftGroupId, + splitDirection: 'right' + }) + const split = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(split.tabGroups).toHaveLength(2) + const rightGroupId = split.tabGroups!.find((g) => g.id !== leftGroupId)!.id + + // Create a terminal targeting the LEFT (now non-active) group. + await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + targetGroupId: leftGroupId, + activate: true + }) + + const after = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const left = after.tabGroups!.find((g) => g.id === leftGroupId)! + const right = after.tabGroups!.find((g) => g.id === rightGroupId)! + expect(left.tabOrder).toHaveLength(2) // original + the targeted create + expect(right.tabOrder).toHaveLength(1) // unchanged + }) + + it('appendBrowserTabOrder keeps a browser in its group across rebuilds (durability)', () => { + const runtime = new OrcaRuntimeService(store) + const groups = [ + { id: 'left', activeTabId: 'web-terminal-a', tabOrder: ['web-terminal-a'] }, + { id: 'right', activeTabId: 'web-terminal-b', tabOrder: ['web-terminal-b'] } + ] + + // First create: a new browser targeted at the RIGHT group lands there. + const afterCreate = runtime['appendBrowserTabOrder'](groups, ['browser-1'], { + tabId: 'browser-1', + groupId: 'right' + }) + expect(afterCreate.find((g) => g.id === 'right')!.tabOrder).toContain('browser-1') + expect(afterCreate.find((g) => g.id === 'left')!.tabOrder).not.toContain('browser-1') + + // Rebuild: the terminal distributor drops the browser id (terminal-only), so + // appendBrowserTabOrder must restore it to its prior group, not group[0]. + const rebuiltGroups = [ + { id: 'left', activeTabId: 'web-terminal-a', tabOrder: ['web-terminal-a'] }, + { id: 'right', activeTabId: 'web-terminal-b', tabOrder: ['web-terminal-b'] } + ] + const priorAssignment = runtime['collectBrowserGroupAssignment'](afterCreate, ['browser-1']) + const afterRebuild = runtime['appendBrowserTabOrder']( + rebuiltGroups, + ['browser-1'], + undefined, + priorAssignment + ) + expect(afterRebuild.find((g) => g.id === 'right')!.tabOrder).toContain('browser-1') + expect(afterRebuild.find((g) => g.id === 'left')!.tabOrder).not.toContain('browser-1') + }) + it('keeps preserved headless mobile session publication epochs idempotent', async () => { const runtime = new OrcaRuntimeService(store) runtime.syncWindowGraph(0, { @@ -11773,7 +12174,7 @@ describe('OrcaRuntimeService', () => { expect(result.tab).toMatchObject({ parentTabId: 'tab-renderer', isActive: false }) }) - it('reports browser tab creation as unsupported for headless runtime servers', async () => { + it('reports browser tab creation as unsupported for a windowless host with no offscreen backend', async () => { const runtime = new OrcaRuntimeService(store) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) @@ -11781,10 +12182,22 @@ describe('OrcaRuntimeService', () => { runtime.browserTabCreate({ worktree: `id:${TEST_WORKTREE_ID}`, url: 'https://example.com' }) ).rejects.toMatchObject({ code: 'browser_error', - message: expect.stringContaining('headless orca serve') + message: expect.stringContaining('does not support browser panes') }) }) + it('creates a browser tab via the offscreen backend for a headless runtime server', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const createTab = vi.fn(async () => ({ browserPageId: 'page-headless' })) + runtime.setOffscreenBrowserBackend({ createTab, closeTab: vi.fn() }) + + await expect( + runtime.browserTabCreate({ worktree: `id:${TEST_WORKTREE_ID}`, url: 'https://example.com' }) + ).resolves.toEqual({ browserPageId: 'page-headless' }) + expect(createTab).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://example.com' })) + }) + it('cancels an in-flight same-connection browser screencast before replacing it', async () => { const runtime = createRuntime() const firstStart = deferred<{ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 844f10d85b8..051eb52b86a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -97,6 +97,7 @@ import type { MemorySnapshot, TabGroupLayoutNode, TerminalLayoutSnapshot, + TerminalPaneLayoutNode, TerminalTab, TuiAgent, WorkspaceCreateTelemetrySource, @@ -193,9 +194,11 @@ import { toDetectedWorktree } from '../../shared/worktree-ownership' import { + BROWSER_HEADLESS_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_CAPABILITIES, - RUNTIME_PROTOCOL_VERSION + RUNTIME_PROTOCOL_VERSION, + type RuntimeCapability } from '../../shared/protocol-version' import type { WorkspacePortKillRequest, @@ -245,6 +248,7 @@ import type { RuntimeMobileSessionTabGroup, RuntimeMobileSessionSnapshotTab, RuntimeMobileSessionTerminalTab, + RuntimeMobileSessionBrowserTab, RuntimeMobileSessionTabsRemovedResult, RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot, @@ -257,6 +261,11 @@ import type { } from '../../shared/runtime-types' import type { AutomationService } from '../automations/service' import { RuntimeBrowserCommands } from './orca-runtime-browser' +import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout' +import { + buildHeadlessTabGroupMove, + buildHeadlessTabGroupSplit +} from './headless-tab-group-split-layout' import { RuntimeEmulatorCommands, setEmulatorBridge } from './orca-runtime-emulator' import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher' import type { EmulatorBridge } from '../emulator/emulator-bridge' @@ -276,6 +285,7 @@ import { joinWorktreeRelativePath } from './runtime-relative-paths' import { collectMemorySnapshot } from '../memory/collector' import { BrowserWindow, ipcMain } from 'electron' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import type { BrowserBackend } from '../browser/browser-backend' import { BrowserError } from '../browser/cdp-bridge' import { getPRForBranch, @@ -1719,6 +1729,7 @@ export class OrcaRuntimeService { private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() private forkBackfillStarted = false private agentBrowserBridge: AgentBrowserBridge | null = null + private offscreenBrowserBackend: BrowserBackend | null = null private emulatorBridge: EmulatorBridge | null = null private resolvedWorktreeCache: ResolvedWorktreeCache | null = null private resolvedWorktreeInFlight: ResolvedWorktreeInFlight | null = null @@ -2370,9 +2381,21 @@ export class OrcaRuntimeService { } getStatus(): RuntimeStatus { - const capabilities = this.getAvailableAuthoritativeWindow() - ? [...RUNTIME_CAPABILITIES] - : RUNTIME_CAPABILITIES.filter((capability) => capability !== 'browser.screencast.v1') + // Why: browser panes need a backend that can create and stream a page. A + // desktop renderer provides one via ; a headless serve provides one + // via the offscreen backend. Either way the same browser.screencast.v1 path + // works, so advertise it when either is present. browser.headless.v1 + // additionally tells clients this host owns browser pages with no renderer, + // so they must not fall back to a local desktop browser tab. + const hasRenderer = Boolean(this.getAvailableAuthoritativeWindow()) + const hasOffscreen = !hasRenderer && Boolean(this.offscreenBrowserBackend) + const canBrowse = hasRenderer || hasOffscreen + const capabilities: RuntimeCapability[] = RUNTIME_CAPABILITIES.filter( + (capability) => capability !== 'browser.screencast.v1' || canBrowse + ) + if (hasOffscreen) { + capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) + } return { runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, @@ -2452,6 +2475,14 @@ export class OrcaRuntimeService { return this.agentBrowserBridge } + setOffscreenBrowserBackend(backend: BrowserBackend | null): void { + this.offscreenBrowserBackend = backend + } + + getOffscreenBrowserBackend(): BrowserBackend | null { + return this.offscreenBrowserBackend + } + setEmulatorBridge(bridge: EmulatorBridge | null): void { this.emulatorBridge = bridge setEmulatorBridge(bridge) @@ -2634,19 +2665,32 @@ export class OrcaRuntimeService { options.force !== true && options.onlyServeOwnedTerminals !== true ) { + // Why: terminals are stable/persisted so we normally skip a rebuild, but + // offscreen browser tabs are live and may have been created/closed since. + // Reconcile just the browser tabs against the live bridge instead of + // leaving a stale snapshot that omits a freshly-opened browser tab. + this.reconcileHeadlessMobileSessionBrowserTabs(entryWorktreeId, existing) continue } - const tabs = this.buildHeadlessMobileSessionTerminalTabs( + const terminalTabs = this.buildHeadlessMobileSessionTerminalTabs( entryWorktreeId, persistedTabs ).filter( (tab) => options.onlyServeOwnedTerminals !== true || this.hasServeOwnedPtyBinding(tab) ) + // Why: offscreen browser panes are live-only (no persisted session entry), + // so include them on every hydrate regardless of the onlyServeOwnedTerminals + // filter, which is about terminal PTY ownership and never applies to browsers. + const browserTabs = this.buildHeadlessMobileSessionBrowserTabs(entryWorktreeId) + const tabs: RuntimeMobileSessionSnapshotTab[] = [...terminalTabs, ...browserTabs] if (tabs.length === 0) { continue } - const activeTab = this.pickHeadlessActiveTerminalTab(tabs) - const tabOrder = this.collectHeadlessParentTabOrder(tabs) + const activeTab = this.pickHeadlessActiveTerminalTab(terminalTabs) + const tabOrder = [ + ...this.collectHeadlessParentTabOrder(terminalTabs), + ...browserTabs.map((tab) => tab.id) + ] const groupId = this.getHeadlessMobileSessionGroupId(entryWorktreeId) const mergedTabs = options.onlyServeOwnedTerminals === true && existing @@ -2655,10 +2699,28 @@ export class OrcaRuntimeService { const mergedActiveTab = existing?.tabs.find((tab) => tab.id === existing.activeTabId) ?? activeTab ?? - (mergedTabs[0]?.type === 'terminal' ? mergedTabs[0] : null) + mergedTabs[0] ?? + null const mergedTerminalTabs = mergedTabs.filter( (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' ) + const mergedBrowserOrder = mergedTabs + .filter((tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser') + .map((tab) => tab.id) + // Why: a persisted multi-group split must be restored on cold rebuild, or + // the headless serve coalesces the user's group layout back into one group + // (the persisted tabGroups/tabGroupLayouts would otherwise be write-only). + const persistedGroups = session.tabGroups?.[entryWorktreeId] + const persistedLayout = session.tabGroupLayouts?.[entryWorktreeId] + const hasPersistedSplit = + options.onlyServeOwnedTerminals !== true && + persistedGroups !== undefined && + persistedGroups.length > 1 + const activeTopLevelId = mergedActiveTab + ? mergedActiveTab.type === 'terminal' + ? mergedActiveTab.parentTabId + : mergedActiveTab.id + : null this.mobileSessionTabsByWorktree.set(entryWorktreeId, { worktree: existing?.worktree ?? entryWorktreeId, publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`, @@ -2666,26 +2728,164 @@ export class OrcaRuntimeService { activeGroupId: existing?.activeGroupId ?? groupId, activeTabId: mergedActiveTab?.id ?? null, activeTabType: mergedActiveTab?.type ?? null, - tabGroups: - options.onlyServeOwnedTerminals === true && existing?.tabGroups - ? this.mergeMobileSessionTabGroups( - entryWorktreeId, - existing.tabGroups, - mergedTerminalTabs, - mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null + tabGroups: hasPersistedSplit + ? this.appendBrowserTabOrder( + this.distributeHeadlessTabsAcrossGroups( + persistedGroups.map((group) => ({ + id: group.id, + activeTabId: group.activeTabId, + tabOrder: [...group.tabOrder], + ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) + })), + this.collectHeadlessParentTabOrder(mergedTerminalTabs), + activeTopLevelId + ), + mergedBrowserOrder, + undefined, + // Why: distribute drops browser ids (terminal-only), so carry each + // browser's persisted group forward instead of coalescing left. + this.collectBrowserGroupAssignment(persistedGroups, mergedBrowserOrder) + ) + : options.onlyServeOwnedTerminals === true && existing?.tabGroups + ? this.appendBrowserTabOrder( + this.mergeMobileSessionTabGroups( + entryWorktreeId, + existing.tabGroups, + mergedTerminalTabs, + mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null + ), + mergedBrowserOrder ) : [ { id: groupId, - activeTabId: activeTab?.parentTabId ?? tabOrder[0] ?? null, + activeTabId: mergedActiveTab?.id + ? (activeTab?.parentTabId ?? mergedActiveTab.id) + : (tabOrder[0] ?? null), tabOrder } ], + ...(hasPersistedSplit && persistedLayout ? { tabGroupLayout: persistedLayout } : {}), tabs: mergedTabs }) } } + // Why: keep an existing snapshot's browser tabs in sync with the live bridge + // without rebuilding stable terminal state. Replaces browser entries with the + // current live set and rewrites the browser portion of the primary group order. + private reconcileHeadlessMobileSessionBrowserTabs( + worktreeId: string, + existing: RuntimeMobileSessionTabsSnapshot + ): void { + if (!this.offscreenBrowserBackend) { + return + } + const liveBrowserTabs = this.buildHeadlessMobileSessionBrowserTabs(worktreeId) + const liveIds = liveBrowserTabs.map((tab) => tab.id) + const existingBrowserIds = existing.tabs + .filter((tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser') + .map((tab) => tab.id) + const unchanged = + liveIds.length === existingBrowserIds.length && + liveIds.every((id, index) => existingBrowserIds[index] === id) + if (unchanged) { + return + } + const nonBrowserTabs = existing.tabs.filter((tab) => tab.type !== 'browser') + const nextTabs: RuntimeMobileSessionSnapshotTab[] = [...nonBrowserTabs, ...liveBrowserTabs] + const liveIdSet = new Set(liveIds) + const tabGroups = this.appendBrowserTabOrder( + (existing.tabGroups ?? []).map((group) => ({ + ...group, + // Drop closed browser ids; appendBrowserTabOrder re-adds the live ones. + tabOrder: group.tabOrder.filter( + (id) => liveIdSet.has(id) || !existingBrowserIds.includes(id) + ) + })), + liveIds + ) + const activeStillPresent = nextTabs.some((tab) => tab.id === existing.activeTabId) + const active = activeStillPresent + ? null + : (nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null) + this.mobileSessionTabsByWorktree.set(worktreeId, { + ...existing, + publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`, + snapshotVersion: existing.snapshotVersion + 1, + ...(activeStillPresent + ? {} + : { activeTabId: active?.id ?? null, activeTabType: active?.type ?? null }), + tabGroups, + tabs: nextTabs + }) + } + + // Why: browser session tabs have no parentTabId so the terminal-only group + // builder drops them from tabOrder; this re-adds their ids to a group. + // Browser tabs are live-only (no persisted session entry), but their GROUP + // membership must still survive snapshot rebuilds like terminals'. The + // passed-in groups already encode each browser's group (carried from the prior + // snapshot / persisted tabGroups), so keep each existing browser id where it + // is; only a genuinely-new browser id goes to its create-target group (when + // that group exists) and otherwise to the first group. Previously every + // browser was force-pushed into group[0], so opening a browser in the right + // split group always snapped it back to the left on the next rebuild. + private appendBrowserTabOrder( + groups: readonly RuntimeMobileSessionTabGroup[], + browserTabIds: readonly string[], + newTabAssignment?: { tabId: string; groupId: string }, + // browserPageId -> groupId from the prior/persisted groups. The terminal + // distributor rebuilds tabOrder from terminal ids only and drops browser + // ids, so this carries each browser's group across rebuilds. + priorGroupByBrowserId?: ReadonlyMap + ): RuntimeMobileSessionTabGroup[] { + if (browserTabIds.length === 0) { + return [...groups] + } + const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) + if (next.length === 0) { + return next + } + const groupById = new Map(next.map((group) => [group.id, group])) + const ownerGroupByTabId = new Map() + for (const group of next) { + for (const id of group.tabOrder) { + ownerGroupByTabId.set(id, group) + } + } + for (const id of browserTabIds) { + if (ownerGroupByTabId.has(id)) { + continue + } + const priorGroupId = priorGroupByBrowserId?.get(id) + const targetGroup = + (newTabAssignment?.tabId === id ? groupById.get(newTabAssignment.groupId) : undefined) ?? + (priorGroupId ? groupById.get(priorGroupId) : undefined) ?? + next[0]! + targetGroup.tabOrder.push(id) + } + return next + } + + // browserPageId -> groupId from a set of groups (the persisted/prior layout), + // so a browser stays in its group across rebuilds that drop browser ids. + private collectBrowserGroupAssignment( + groups: readonly RuntimeMobileSessionTabGroup[] | undefined, + browserTabIds: readonly string[] + ): Map { + const browserIdSet = new Set(browserTabIds) + const assignment = new Map() + for (const group of groups ?? []) { + for (const id of group.tabOrder) { + if (browserIdSet.has(id)) { + assignment.set(id, group.id) + } + } + } + return assignment + } + private isServeOwnedPtyId(ptyId: string | null | undefined): boolean { return typeof ptyId === 'string' && ptyId.startsWith('serve-') } @@ -2757,25 +2957,54 @@ export class OrcaRuntimeService { tabOrder: [] } ] - const target = nextGroups[0]! - for (const tabId of parentTabOrder) { - if (!target.tabOrder.includes(tabId)) { - target.tabOrder.push(tabId) + // Why: keep each tab in the group that already owns it (a multi-group split + // must survive the merge), drop tabs no longer present, and route only + // genuinely-new tabs into the active group — never funnel everything into + // group[0], which duplicated/coalesced tabs that lived in other groups. + const ownerGroupId = new Map() + for (const group of nextGroups) { + for (const tabId of group.tabOrder) { + ownerGroupId.set(tabId, group.id) } } - const activeParentId = - activeTab?.parentTabId ?? target.activeTabId ?? target.tabOrder[0] ?? null - target.activeTabId = - activeParentId && target.tabOrder.includes(activeParentId) - ? activeParentId - : (target.tabOrder[0] ?? null) + const liveTabIds = new Set(parentTabOrder) + const activeParentId = activeTab?.parentTabId ?? null + const activeGroupId = + (activeParentId ? ownerGroupId.get(activeParentId) : undefined) ?? nextGroups[0]!.id + const retainedOrder = new Map(nextGroups.map((group) => [group.id, []])) + for (const tabId of parentTabOrder) { + const groupId = ownerGroupId.get(tabId) ?? activeGroupId + retainedOrder.get(groupId)?.push(tabId) + } return nextGroups + .map((group) => { + const tabOrder = retainedOrder.get(group.id) ?? [] + const keptActive = + group.activeTabId && tabOrder.includes(group.activeTabId) && liveTabIds.has(group.activeTabId) + ? group.activeTabId + : null + return { + ...group, + tabOrder, + activeTabId: + activeParentId && tabOrder.includes(activeParentId) + ? activeParentId + : (keptActive ?? tabOrder[0] ?? null) + } + }) + .filter((group) => group.tabOrder.length > 0) } private publishPtyBackedMobileSessionTerminal( worktreeId: string, pty: RuntimePtyWorktreeRecord, - args: { tabId: string; leafId: string; title: string | null; activate: boolean } + args: { + tabId: string + leafId: string + title: string | null + activate: boolean + split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } + } ): void { const existing = this.mobileSessionTabsByWorktree.get(worktreeId) const title = args.title ?? getLatestPtyTitle(pty) ?? 'Terminal' @@ -2785,10 +3014,21 @@ export class OrcaRuntimeService { candidate.parentTabId === args.tabId && candidate.leafId === args.leafId ) + // Why: a split inserts into the parent tab's layout, which lives on the + // sibling surface, not this new leaf's (empty) existing surface. + const baseLayout = args.split + ? (existing?.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && + candidate.parentTabId === args.tabId && + candidate.leafId === args.split!.splitFromLeafId + )?.parentLayout ?? existingTab?.parentLayout) + : existingTab?.parentLayout const parentLayout = this.buildMaterializedHeadlessParentLayout( args.leafId, pty.ptyId, - existingTab?.parentLayout + baseLayout, + args.split ) const tab: RuntimeMobileSessionTerminalTab = { type: 'terminal', @@ -2811,6 +3051,13 @@ export class OrcaRuntimeService { const tabs = this.mergeMobileSessionSnapshotTabs( existingTabs.map((candidate) => ({ ...candidate, + // Why: the client picks one sibling's parentLayout to render the whole + // tab; a split must update every sibling surface to the new tree, or a + // stale single-leaf sibling makes the client fall back to a default + // direction ("Split Right" renders as down). + ...(args.split && candidate.type === 'terminal' && candidate.parentTabId === args.tabId + ? { parentLayout } + : {}), isActive: tab.isActive ? false : candidate.isActive })), [tab] @@ -2896,12 +3143,41 @@ export class OrcaRuntimeService { ...(ptyId ? { ptyId } : {}), ...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}), ...(layout ? { parentLayout: this.cloneTerminalLayoutSnapshot(layout) } : {}), + ...(tab.color != null ? { color: tab.color } : {}), + ...(tab.isPinned ? { isPinned: true } : {}), isActive: this.isPersistedTerminalLeafActive(worktreeId, tab.id, leafId, layout) } }) }) } + // Why: headless serve backs browser panes with offscreen WebContents that live + // only in the BrowserManager, never in a renderer graph. Without surfacing them + // as session tabs, a session.tabs snapshot (e.g. on terminal open) prunes the + // paired browser tab and closing it fails with tab_not_found. Synthesize browser + // session tabs from the live bridge so they are first-class alongside terminals. + private buildHeadlessMobileSessionBrowserTabs( + worktreeId: string + ): RuntimeMobileSessionBrowserTab[] { + if (!this.offscreenBrowserBackend || !this.agentBrowserBridge?.tabList) { + return [] + } + return this.agentBrowserBridge.tabList(worktreeId).tabs.map((tab) => ({ + type: 'browser' as const, + // Why: an offscreen page has no separate workspace identity, so the page id + // is its own workspace id (matches the server's browserWorkspaceId fallback). + id: tab.browserPageId, + title: tab.title || tab.url || 'Browser', + browserWorkspaceId: tab.browserPageId, + browserPageId: tab.browserPageId, + url: tab.url || 'about:blank', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: tab.active === true + })) + } + private collectPersistedTerminalLeafIds(layout: TerminalLayoutSnapshot | undefined): string[] { if (!layout) { return [] @@ -3000,40 +3276,129 @@ export class OrcaRuntimeService { return order } + // Why: the group tab order must follow actual creation/insertion order across + // both terminals and browsers, not list terminals first. A terminal's top-level + // id is its parentTabId (split leaves share one); a browser's is its own id. + private collectHeadlessTopLevelTabOrder( + tabs: readonly RuntimeMobileSessionSnapshotTab[] + ): string[] { + const order: string[] = [] + const seen = new Set() + for (const tab of tabs) { + const topLevelId = tab.type === 'terminal' ? tab.parentTabId : tab.id + if (!seen.has(topLevelId)) { + seen.add(topLevelId) + order.push(topLevelId) + } + } + return order + } + private getHeadlessMobileSessionGroupId(worktreeId: string): string { return `headless-terminals:${worktreeId}` } private buildHeadlessMobileSessionTabGroups( worktreeId: string, - tabs: readonly RuntimeMobileSessionTerminalTab[], - activeTab: RuntimeMobileSessionTerminalTab | null, - existingGroups?: readonly RuntimeMobileSessionTabGroup[] + tabs: readonly RuntimeMobileSessionSnapshotTab[], + activeTab: RuntimeMobileSessionSnapshotTab | null, + existingGroups?: readonly RuntimeMobileSessionTabGroup[], + // Why: a new tab created via a specific group's "+" must land in THAT group, + // not the active one — otherwise every "+" in a split funnels to one group. + newTabAssignment?: { tabId: string; groupId: string } ): RuntimeMobileSessionTabGroup[] { - const groupId = existingGroups?.[0]?.id ?? this.getHeadlessMobileSessionGroupId(worktreeId) - const tabOrder = this.collectHeadlessParentTabOrder(tabs) - const activeParentTabId = - activeTab?.parentTabId ?? + // Why: order across terminals and browsers in their actual array order so a + // tab opened after a browser tab lands to its right, not regrouped before it. + const tabOrder = this.collectHeadlessTopLevelTabOrder(tabs) + const topLevelOf = (tab: RuntimeMobileSessionSnapshotTab): string => + tab.type === 'terminal' ? tab.parentTabId : tab.id + const activeTopLevelId = + (activeTab ? topLevelOf(activeTab) : null) ?? existingGroups?.[0]?.activeTabId ?? - tabs.find((tab) => tab.isActive)?.parentTabId ?? + (() => { + const active = tabs.find((tab) => tab.isActive) + return active ? topLevelOf(active) : null + })() ?? tabOrder[0] ?? null + + // Why: when the user has split tabs into multiple groups, preserve that + // assignment across rebuilds instead of coalescing back to one group. + if (existingGroups && existingGroups.length > 1) { + return this.distributeHeadlessTabsAcrossGroups( + existingGroups, + tabOrder, + activeTopLevelId, + newTabAssignment + ) + } + + const groupId = existingGroups?.[0]?.id ?? this.getHeadlessMobileSessionGroupId(worktreeId) return [ { id: groupId, activeTabId: - activeParentTabId && tabOrder.includes(activeParentTabId) - ? activeParentTabId + activeTopLevelId && tabOrder.includes(activeTopLevelId) + ? activeTopLevelId : (tabOrder[0] ?? null), tabOrder } ] } + // Distribute live top-level tabs into the existing multi-group structure, + // keeping each tab in its group; tabs new since the last snapshot join the + // active group. Emptied groups are dropped so a closed split collapses. + private distributeHeadlessTabsAcrossGroups( + existingGroups: readonly RuntimeMobileSessionTabGroup[], + tabOrder: readonly string[], + activeTopLevelId: string | null, + newTabAssignment?: { tabId: string; groupId: string } + ): RuntimeMobileSessionTabGroup[] { + const groupIdByTabId = new Map() + for (const group of existingGroups) { + for (const tabId of group.tabOrder) { + groupIdByTabId.set(tabId, group.id) + } + } + // Why: route a freshly-created tab to the group its "+" was clicked in, + // when that group still exists; otherwise fall through to the active group. + const hasTargetGroup = + newTabAssignment !== undefined && + existingGroups.some((group) => group.id === newTabAssignment.groupId) + if (hasTargetGroup) { + groupIdByTabId.set(newTabAssignment!.tabId, newTabAssignment!.groupId) + } + const activeGroupId = + (activeTopLevelId ? groupIdByTabId.get(activeTopLevelId) : undefined) ?? + existingGroups[0]!.id + const orderByGroup = new Map(existingGroups.map((group) => [group.id, []])) + for (const tabId of tabOrder) { + const groupId = groupIdByTabId.get(tabId) ?? activeGroupId + orderByGroup.get(groupId)?.push(tabId) + } + return existingGroups + .map((group) => { + const nextOrder = orderByGroup.get(group.id) ?? [] + return { + ...group, + tabOrder: nextOrder, + activeTabId: + activeTopLevelId && nextOrder.includes(activeTopLevelId) + ? activeTopLevelId + : group.activeTabId && nextOrder.includes(group.activeTabId) + ? group.activeTabId + : (nextOrder[0] ?? null) + } + }) + .filter((group) => group.tabOrder.length > 0) + } + private buildMaterializedHeadlessParentLayout( leafId: string, ptyId: string, - existingLayout: TerminalLayoutSnapshot | undefined + existingLayout: TerminalLayoutSnapshot | undefined, + split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } ): TerminalLayoutSnapshot { if (!existingLayout) { return { @@ -3043,6 +3408,18 @@ export class OrcaRuntimeService { ptyIdsByLeafId: { [leafId]: ptyId } } } + // Why: a split must insert the new leaf into the live layout tree with the + // requested direction, or the published snapshot keeps the old single-leaf + // root and the split renders with a fallback direction ("Split Right" lands + // as a top/bottom split). Reuse the persisted-split builder for parity. + if (split) { + return buildHeadlessTerminalSplitLayout(this.cloneTerminalLayoutSnapshot(existingLayout), { + leafId, + ptyId, + splitFromLeafId: split.splitFromLeafId, + direction: split.direction + }) + } return { ...this.cloneTerminalLayoutSnapshot(existingLayout), ptyIdsByLeafId: { @@ -3254,9 +3631,6 @@ export class OrcaRuntimeService { ...candidate, isActive: candidate.id === activeTab.id })) - const terminalTabs = tabs.filter( - (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' - ) const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, publicationEpoch: `headless:${Date.now().toString(36)}`, @@ -3265,7 +3639,7 @@ export class OrcaRuntimeService { activeTabType: 'terminal', tabGroups: this.buildHeadlessMobileSessionTabGroups( worktreeId, - terminalTabs, + tabs, activeTab, snapshot.tabGroups ), @@ -3276,6 +3650,36 @@ export class OrcaRuntimeService { this.emitMobileSessionTabsSnapshot(nextSnapshot) } + // Why: a headless split only updated the LIVE session snapshot, never the + // persisted workspace session layout. So a later snapshot rebuild (e.g. on the + // next terminal create) re-derived from the stale single-leaf persisted layout + // and collapsed the split. Persist the new split leaf into the workspace + // session's terminalLayoutsByTabId so the split survives rebuilds. + private persistHeadlessTerminalSplit(args: { + tabId: string + leafId: string + ptyId: string + splitFromLeafId: string + direction: 'horizontal' | 'vertical' + }): void { + const session = this.store?.getWorkspaceSession?.() + if (!session || !this.store?.setWorkspaceSession) { + return + } + const existing = session.terminalLayoutsByTabId?.[args.tabId] + const nextLayout = buildHeadlessTerminalSplitLayout( + existing ? this.cloneTerminalLayoutSnapshot(existing) : undefined, + args + ) + this.store.setWorkspaceSession({ + ...session, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [args.tabId]: nextLayout + } + }) + } + private persistHeadlessTerminalActiveLeaf( worktreeId: string, tab: RuntimeMobileSessionTerminalTab @@ -3341,12 +3745,104 @@ export class OrcaRuntimeService { // just whichever leaf happened to be first in the session snapshot. this.notifier?.closeTerminal(tab.parentTabId) } + } else if (tab.type === 'browser' && this.offscreenBrowserBackend) { + // Why: headless browser tabs are offscreen WebContents with no renderer to + // route closeSessionTab to. Close the page directly and drop it from the + // snapshot so paired clients stop showing it. + await this.closeHeadlessMobileBrowserTab(worktreeId, snapshot!, tab) } else { this.notifier?.closeSessionTab?.(tab.id, worktreeId) } return { closed: true } } + private async closeHeadlessMobileBrowserTab( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionBrowserTab + ): Promise { + if (tab.browserPageId) { + await this.offscreenBrowserBackend?.closeTab(tab.browserPageId).catch(() => {}) + } + const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id) + const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + tabGroups: (snapshot.tabGroups ?? []).map((group) => ({ + ...group, + tabOrder: group.tabOrder.filter((id) => id !== tab.id), + activeTabId: group.activeTabId === tab.id ? null : group.activeTabId + })), + tabs: nextTabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + } + + private markHeadlessBrowserSessionTabActive( + worktreeId: string | undefined, + browserPageId: string, + targetGroupId?: string + ): void { + if (!this.offscreenBrowserBackend || !worktreeId) { + return + } + // Hydrate first so the freshly created browser tab is present in the snapshot. + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const tab = snapshot?.tabs.find( + (candidate): candidate is RuntimeMobileSessionBrowserTab => + candidate.type === 'browser' && candidate.browserPageId === browserPageId + ) + if (!snapshot || !tab) { + return + } + const groups = snapshot.tabGroups ?? [] + const hasTargetGroup = + targetGroupId !== undefined && groups.some((group) => group.id === targetGroupId) + // Why: move the new browser into the group whose "+" was clicked, removing it + // from wherever the rebuild placed it. Only the TARGET group's activeTabId + // (and the global active) change — every other group's active tab is left + // intact, so creating in the right group never resets the left group's tab. + const nextGroups = hasTargetGroup + ? groups.map((group) => { + const withoutTab = group.tabOrder.filter((id) => id !== tab.id) + if (group.id === targetGroupId) { + return { ...group, tabOrder: [...withoutTab, tab.id], activeTabId: tab.id } + } + return withoutTab.length === group.tabOrder.length + ? group + : { ...group, tabOrder: withoutTab } + }) + : groups.map((group) => + group.tabOrder.includes(tab.id) ? { ...group, activeTabId: tab.id } : group + ) + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + ...(hasTargetGroup ? { activeGroupId: targetGroupId } : {}), + activeTabId: tab.id, + activeTabType: 'browser', + tabs: snapshot.tabs.map((candidate) => ({ + ...candidate, + isActive: candidate.id === tab.id + })), + tabGroups: nextGroups + } + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + // Why: browser group membership is otherwise live-only; persist it so a + // later rebuild keeps the browser in its group instead of coalescing left. + if (hasTargetGroup && nextSnapshot.tabGroupLayout) { + this.persistHeadlessTabGroups(worktreeId, nextGroups, nextSnapshot.tabGroupLayout) + } + this.emitMobileSessionTabsSnapshot(nextSnapshot) + } + private closeHeadlessMobileTerminalTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, @@ -3380,10 +3876,8 @@ export class OrcaRuntimeService { activeTabType: active?.type ?? null, tabGroups: this.buildHeadlessMobileSessionTabGroups( worktreeId, - nextTabs.filter( - (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' - ), - active?.type === 'terminal' ? active : null, + nextTabs, + active, snapshot.tabGroups ), tabs: nextTabs @@ -3438,11 +3932,218 @@ export class OrcaRuntimeService { return { moved: true } } + // Why: pane geometry inside a tab (split ratios, expanded pane, pane titles) + // is host-authoritative for remote-server tabs but had no push path, so a + // client divider-drag / expand / pane-rename reverted on the next snapshot. + // Persist the structural fields onto the tab's layout, keeping host-owned + // pty bindings and active leaf. + async updateMobileSessionPaneLayout( + worktreeSelector: string, + args: { + tabId: string + root: TerminalPaneLayoutNode | null + expandedLeafId: string | null + titlesByLeafId?: Record + } + ): Promise<{ updated: true }> { + const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) + const worktreeId = + explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + // Why: when a renderer is authoritative (desktop host reached via shared + // control), it owns pane geometry and republishes it — a headless write here + // would be overwritten and could fight the renderer. Persist only headlessly. + if (this.getAvailableAuthoritativeWindow()) { + return { updated: true } + } + // Why: resolve to the host tab id (older/raw-id clients) so the persisted + // layout entry matches, matching setMobileSessionTabProps. + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const hostTabId = snapshot + ? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId) + : args.tabId + const resolvedArgs = { ...args, tabId: hostTabId } + this.persistHeadlessTerminalPaneLayout(resolvedArgs) + this.applyHeadlessTerminalPaneLayoutToSnapshot(worktreeId, resolvedArgs) + return { updated: true } + } + + // Why: tab color/pin are host-authoritative for remote-server tabs but had no + // push path, so pinning or coloring a tab reverted on the next snapshot and + // was never persisted. Persist to the workspace session + live snapshot. + async setMobileSessionTabProps( + worktreeSelector: string, + args: { tabId: string; color?: string | null; isPinned?: boolean } + ): Promise<{ updated: true }> { + const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) + const worktreeId = + explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + // Why: a renderer-authoritative host owns + republishes tab props, so a + // headless write would be overwritten. Persist only when headless. + if (this.getAvailableAuthoritativeWindow()) { + return { updated: true } + } + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const hostTabId = snapshot + ? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId) + : args.tabId + this.persistHeadlessTerminalTabProps(worktreeId, hostTabId, args) + this.applyHeadlessTerminalTabPropsToSnapshot(worktreeId, hostTabId, args) + return { updated: true } + } + + private persistHeadlessTerminalTabProps( + worktreeId: string, + tabId: string, + props: { color?: string | null; isPinned?: boolean } + ): void { + const session = this.store?.getWorkspaceSession?.() + if (!session || !this.store?.setWorkspaceSession) { + return + } + const tabs = session.tabsByWorktree[worktreeId] + if (!tabs?.some((tab) => tab.id === tabId)) { + return + } + this.store.setWorkspaceSession({ + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [worktreeId]: tabs.map((tab) => + tab.id === tabId + ? { + ...tab, + ...(props.color !== undefined ? { color: props.color } : {}), + ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}) + } + : tab + ) + } + }) + } + + private applyHeadlessTerminalTabPropsToSnapshot( + worktreeId: string, + tabId: string, + props: { color?: string | null; isPinned?: boolean } + ): void { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + return + } + // Why: only terminal tabs persist color/pin today (browser/editor are + // tracked in #5729). Applying to a browser surface here would show a + // transient that reverts on the next rebuild — apply only what we persist. + let changed = false + const tabs = snapshot.tabs.map((tab) => { + if (tab.type !== 'terminal' || tab.parentTabId !== tabId) { + return tab + } + changed = true + return { + ...tab, + ...(props.color !== undefined ? { color: props.color } : {}), + ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}) + } + }) + if (!changed) { + return + } + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + tabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + } + + // Merge the client's pane structure into the persisted tab layout. PTY + // bindings and active leaf stay host-owned; only ratios/expand/titles change. + // terminalLayoutsByTabId is keyed by tab id (worktree-independent). + private persistHeadlessTerminalPaneLayout( + args: { + tabId: string + root: TerminalPaneLayoutNode | null + expandedLeafId: string | null + titlesByLeafId?: Record + } + ): void { + const session = this.store?.getWorkspaceSession?.() + if (!session || !this.store?.setWorkspaceSession) { + return + } + const existing = session.terminalLayoutsByTabId?.[args.tabId] + if (!existing) { + return + } + this.store.setWorkspaceSession({ + ...session, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + ...this.cloneTerminalLayoutSnapshot(existing), + root: args.root ?? existing.root, + expandedLeafId: args.expandedLeafId, + ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) + } + } + }) + } + + private applyHeadlessTerminalPaneLayoutToSnapshot( + worktreeId: string, + args: { + tabId: string + root: TerminalPaneLayoutNode | null + expandedLeafId: string | null + titlesByLeafId?: Record + } + ): void { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + return + } + let changed = false + const tabs = snapshot.tabs.map((tab) => { + if (tab.type !== 'terminal' || tab.parentTabId !== args.tabId || !tab.parentLayout) { + return tab + } + changed = true + return { + ...tab, + parentLayout: { + ...tab.parentLayout, + root: args.root ?? tab.parentLayout.root, + expandedLeafId: args.expandedLeafId, + ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) + } + } + }) + if (!changed) { + return + } + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + tabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + } + private moveHeadlessMobileSessionTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, move: RuntimeMobileSessionTabMove ): RuntimeMobileSessionTabMoveResult { + if (move.kind === 'split') { + return this.splitHeadlessMobileSessionTabGroup(worktreeId, snapshot, move) + } + if (move.kind === 'move-to-group') { + return this.moveHeadlessMobileSessionTabToGroup(worktreeId, snapshot, move) + } if (move.kind !== 'reorder') { throw new Error('renderer_unavailable') } @@ -3465,32 +4166,170 @@ export class OrcaRuntimeService { return aIndex - bIndex }) const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null + const reorderedTargetActiveTabId = + active?.type === 'terminal' + ? active.parentTabId + : active + ? active.id + : (tabOrder[0] ?? null) + // Why: reorder only changes ONE group's order. Preserve every other group so + // a multi-group split isn't deleted by re-sorting tabs in one of its groups. + const existingGroups = snapshot.tabGroups ?? [] + const nextGroups = existingGroups.some((group) => group.id === targetGroup.id) + ? existingGroups.map((group) => + group.id === targetGroup.id + ? { ...group, tabOrder, activeTabId: reorderedTargetActiveTabId } + : group + ) + : [{ ...targetGroup, tabOrder, activeTabId: reorderedTargetActiveTabId }] const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: snapshot.snapshotVersion + 1, activeTabId: active?.id ?? null, activeTabType: active?.type ?? null, - tabGroups: [ - { - ...targetGroup, - tabOrder, - activeTabId: - active?.type === 'terminal' - ? active.parentTabId - : active - ? active.id - : (tabOrder[0] ?? null) - } - ], + tabGroups: nextGroups, tabs: nextTabs } this.persistHeadlessTerminalTabOrder(worktreeId, tabOrder) + if (nextGroups.length > 1 && snapshot.tabGroupLayout) { + this.persistHeadlessTabGroups(worktreeId, nextGroups, snapshot.tabGroupLayout) + } this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) return { moved: true } } + // Why: a drag-to-split-group used to be a client-only change the headless host + // never modeled, so the next snapshot coalesced every tab back into one group. + // Model + persist the multi-group layout so the split survives rebuilds. + private splitHeadlessMobileSessionTabGroup( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + move: Extract + ): RuntimeMobileSessionTabMoveResult { + const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId) + if (!hostTabId) { + throw new Error('tab_not_found') + } + const split = buildHeadlessTabGroupSplit({ + groups: snapshot.tabGroups ?? [], + layout: snapshot.tabGroupLayout, + tabId: hostTabId, + targetGroupId: move.targetGroupId, + splitDirection: move.splitDirection, + newGroupId: randomUUID() + }) + if (!split) { + // Renderer treats an unsplittable drop (e.g. last tab onto its own group) + // as a no-op; mirror that instead of churning the snapshot. + return { moved: true } + } + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + activeGroupId: split.newGroupId, + tabGroups: split.groups, + tabGroupLayout: split.layout + } + this.persistHeadlessTabGroups(worktreeId, split.groups, split.layout) + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + return { moved: true } + } + + // Move a tab into an existing group on a headless serve (non-split drop). + private moveHeadlessMobileSessionTabToGroup( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + move: Extract + ): RuntimeMobileSessionTabMoveResult { + const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId) + if (!hostTabId) { + throw new Error('tab_not_found') + } + const moved = buildHeadlessTabGroupMove({ + groups: snapshot.tabGroups ?? [], + layout: snapshot.tabGroupLayout, + tabId: hostTabId, + targetGroupId: move.targetGroupId, + index: move.index + }) + if (!moved) { + // Same-group / missing-target drop is a renderer no-op; mirror that. + return { moved: true } + } + const layout = moved.layout ?? { type: 'leaf' as const, groupId: move.targetGroupId } + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + activeGroupId: move.targetGroupId, + tabGroups: moved.groups, + tabGroupLayout: layout + } + this.persistHeadlessTabGroups(worktreeId, moved.groups, layout) + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + return { moved: true } + } + + // Persist the headless tab-GROUP layout so snapshot rebuilds keep the split. + private persistHeadlessTabGroups( + worktreeId: string, + groups: readonly RuntimeMobileSessionTabGroup[], + layout: TabGroupLayoutNode + ): void { + const session = this.store?.getWorkspaceSession?.() + if (!session || !this.store?.setWorkspaceSession) { + return + } + this.store.setWorkspaceSession({ + ...session, + tabGroups: { + ...session.tabGroups, + [worktreeId]: groups.map((group) => ({ + id: group.id, + worktreeId, + activeTabId: group.activeTabId, + tabOrder: [...group.tabOrder], + ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) + })) + }, + tabGroupLayouts: { + ...session.tabGroupLayouts, + [worktreeId]: layout + } + }) + } + + // Persist a manual terminal rename so a headless rebuild keeps the title + // instead of reverting to the generated/default one. + private persistHeadlessTerminalTitle( + worktreeId: string, + tabId: string, + title: string | null + ): void { + const session = this.store?.getWorkspaceSession?.() + if (!session || !this.store?.setWorkspaceSession) { + return + } + const tabs = session.tabsByWorktree[worktreeId] + if (!tabs?.some((tab) => tab.id === tabId)) { + return + } + this.store.setWorkspaceSession({ + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [worktreeId]: tabs.map((tab) => + tab.id === tabId ? { ...tab, customTitle: title } : tab + ) + } + }) + } + private normalizeMobileSessionTabOrder( snapshot: RuntimeMobileSessionTabsSnapshot | undefined, targetGroup: RuntimeMobileSessionTabGroup, @@ -12339,6 +13178,19 @@ export class OrcaRuntimeService { advertisedUrlWatcher.forgetWorktree(worktreeId) serveSimStateWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) + this.closeHeadlessBrowserPagesForWorktree(worktreeId) + } + + // Why: headless offscreen browser pages are main-process BrowserWindows that + // outlive a worktree unless explicitly closed — removing a worktree without + // closing its open panes leaks the windows for the life of the serve process. + private closeHeadlessBrowserPagesForWorktree(worktreeId: string): void { + if (!this.offscreenBrowserBackend || !this.agentBrowserBridge?.tabList) { + return + } + for (const tab of this.agentBrowserBridge.tabList(worktreeId).tabs) { + void this.offscreenBrowserBackend.closeTab(tab.browserPageId).catch(() => {}) + } } private rememberPreservedBranchCleanupTarget( @@ -12814,7 +13666,15 @@ export class OrcaRuntimeService { const pty = this.getLivePtyForHandle(handle) if (pty) { pty.pty.title = title + // Why: a manual rename must outrank later agent OSC title updates (which + // win by timestamp), so stamp it as the freshest title. + pty.pty.titleUpdatedAt = Date.now() this.touchMobileSessionSnapshotsForPty(pty.pty.ptyId) + // Why: without a renderer the rename only lived on the live pty and was + // lost on restart. Persist customTitle so a headless rebuild keeps it. + if (!this.notifier?.renameTerminal && pty.pty.tabId) { + this.persistHeadlessTerminalTitle(pty.pty.worktreeId, pty.pty.tabId, title) + } for (const leaf of this.leaves.values()) { if (leaf.ptyId === pty.pty.ptyId) { this.notifier?.renameTerminal(leaf.tabId, title) @@ -12844,6 +13704,11 @@ export class OrcaRuntimeService { leafId?: string sessionId?: string persistHostSessionBinding?: boolean + // Why: the headless mobile-session create publishes its own authoritative + // snapshot (with the correct target group) right after spawn. Skip the + // intermediate pty-backed publish so the new tab doesn't briefly flash in + // the wrong (active) group before the corrected snapshot lands. + deferMobileSessionPublish?: boolean } = {} ): Promise { // Why: pre-diff createTerminal fell back to the renderer's active worktree @@ -12941,7 +13806,7 @@ export class OrcaRuntimeService { pty.paneKey = paneKey } const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle - if (pty) { + if (pty && opts.deferMobileSessionPublish !== true) { this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, { tabId, leafId, @@ -13087,7 +13952,8 @@ export class OrcaRuntimeService { command, opts.startupCommandDelivery, undefined, - opts.agent + opts.agent, + opts.targetGroupId ) } const requestId = randomUUID() @@ -13178,7 +14044,8 @@ export class OrcaRuntimeService { command?: string, startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'], identity?: { tabId: string; leafId: string; sessionId?: string }, - launchAgent?: TuiAgent + launchAgent?: TuiAgent, + targetGroupId?: string ): Promise { const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) // Why: SshPtyProvider treats sessionId as a relay reattach request. Only @@ -13198,7 +14065,10 @@ export class OrcaRuntimeService { : stableSessionId ? { sessionId: stableSessionId } : {}), - persistHostSessionBinding: true + persistHostSessionBinding: true, + // Why: this method publishes the authoritative snapshot (with the target + // group) below; skip the intermediate publish to avoid a wrong-group flash. + deferMobileSessionPublish: true }) const livePty = this.getLivePtyForHandle(terminal.handle) if (!livePty) { @@ -13249,17 +14119,24 @@ export class OrcaRuntimeService { worktree: worktreeId, publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, - activeGroupId: existing?.activeGroupId ?? this.getHeadlessMobileSessionGroupId(worktreeId), + // Why: activating the new tab also focuses its group, so when "+" targeted + // a specific split group, make that group active too. + activeGroupId: + activate && targetGroupId + ? targetGroupId + : (existing?.activeGroupId ?? this.getHeadlessMobileSessionGroupId(worktreeId)), activeTabId: activate ? tab.id : (existing?.activeTabId ?? null), activeTabType: activate ? 'terminal' : (existing?.activeTabType ?? null), tabGroups: this.buildHeadlessMobileSessionTabGroups( worktreeId, - tabs.filter( - (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' - ), + tabs, activate ? tab : null, - existing?.tabGroups + existing?.tabGroups, + targetGroupId ? { tabId: parentTabId, groupId: targetGroupId } : undefined ), + // Why: keep the group split geometry when a new tab is created, otherwise + // opening a terminal while split loses the groups' arrangement. + ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), tabs } this.mobileSessionTabsByWorktree.set(worktreeId, next) @@ -13616,7 +14493,17 @@ export class OrcaRuntimeService { tabId: parentTabId, leafId, title: null, - activate: opts.activate !== false + activate: opts.activate !== false, + split: { splitFromLeafId: parsedPaneKey.leafId, direction } + }) + // Why: persist the split into the workspace session so a later snapshot + // rebuild keeps it instead of collapsing back to a single pane. + this.persistHeadlessTerminalSplit({ + tabId: parentTabId, + leafId, + ptyId: createdPty.ptyId, + splitFromLeafId: parsedPaneKey.leafId, + direction }) } @@ -15228,6 +16115,15 @@ export class OrcaRuntimeService { snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionSnapshotTab ): boolean { + // Why: headless offscreen browser tabs live only on the server and are + // re-derived from the live bridge on each hydrate, so a renderer-graph merge + // must keep them rather than prune them as "not in the renderer graph". + if (tab.type === 'browser') { + return ( + Boolean(this.offscreenBrowserBackend) && + this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) + ) + } if (tab.type !== 'terminal') { return false } @@ -15508,6 +16404,8 @@ export class OrcaRuntimeService { ...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}), ...(agentStatus ?? this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)), ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), + ...(tab.color != null ? { color: tab.color } : {}), + ...(tab.isPinned ? { isPinned: true } : {}), isActive: tab.isActive, ...(terminalHandle ? { status: 'ready' as const, terminal: terminalHandle } @@ -18781,7 +19679,12 @@ export class OrcaRuntimeService { getAgentBrowserBridge: () => this.agentBrowserBridge, resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), getAuthoritativeWindow: () => this.getAuthoritativeWindow(), - getAvailableAuthoritativeWindow: () => this.getAvailableAuthoritativeWindow() + getAvailableAuthoritativeWindow: () => this.getAvailableAuthoritativeWindow(), + getOffscreenBrowserBackend: () => this.offscreenBrowserBackend, + // Why: bind the method directly rather than re-listing params in a wrapper + // arrow — a hand-listed wrapper silently dropped targetGroupId before, so a + // new browser opened in the right split group landed in the left. + markHeadlessBrowserSessionTabActive: this.markHeadlessBrowserSessionTabActive.bind(this) }) private readonly emulatorCommands = new RuntimeEmulatorCommands({ diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 50b854efe65..db916054392 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -120,7 +120,10 @@ export const TabCreate = z.object({ profileId: OptionalString, waitForRegistration: z.boolean().optional(), // User-initiated opens focus the tab; agent/automation opens stay background. - activate: z.boolean().optional() + activate: z.boolean().optional(), + // Why: the split group whose "+" was clicked, so a headless host places the + // new browser tab there instead of coalescing into the first/active group. + targetGroupId: OptionalString }) export const TabShow = z.object({ diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts new file mode 100644 index 00000000000..2af6206f0e1 --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { UpdatePaneLayout } from './session-tabs-schemas' + +const WT = 'id:wt' + +describe('UpdatePaneLayout.root (untrusted remote pane-layout tree)', () => { + it('accepts a valid split tree', () => { + const parsed = UpdatePaneLayout.parse({ + worktree: WT, + tabId: 'tab', + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'a' }, + second: { type: 'leaf', leafId: 'b' }, + ratio: 0.5 + } + }) + expect(parsed.root).toMatchObject({ type: 'split', direction: 'horizontal' }) + }) + + it('accepts a null root', () => { + expect(UpdatePaneLayout.parse({ worktree: WT, tabId: 'tab', root: null }).root).toBeNull() + }) + + it('rejects an over-deep tree instead of overflowing the stack', () => { + // Build a tree deeper than the cap (64) without recursion in the test. + let node: unknown = { type: 'leaf', leafId: 'x' } + for (let i = 0; i < 5000; i++) { + node = { + type: 'split', + direction: 'vertical', + first: node, + second: { type: 'leaf', leafId: 'y' } + } + } + expect(() => UpdatePaneLayout.parse({ worktree: WT, tabId: 'tab', root: node })).toThrow() + }) + + it('rejects a leaf with an invalid leafId', () => { + expect(() => + UpdatePaneLayout.parse({ worktree: WT, tabId: 'tab', root: { type: 'leaf', leafId: '' } }) + ).toThrow() + }) + + it('rejects an unknown node type', () => { + expect(() => + UpdatePaneLayout.parse({ worktree: WT, tabId: 'tab', root: { type: 'bogus' } }) + ).toThrow() + }) + + it('rejects a ratio outside 0..1', () => { + expect(() => + UpdatePaneLayout.parse({ + worktree: WT, + tabId: 'tab', + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'a' }, + second: { type: 'leaf', leafId: 'b' }, + ratio: 5 + } + }) + ).toThrow() + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts new file mode 100644 index 00000000000..37be39d560f --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -0,0 +1,162 @@ +import { z } from 'zod' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../../shared/types' + +export const WorktreeTabSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +export const SessionTabsUnsubscribe = WorktreeTabSelector.extend({ + subscriptionId: z.string().min(1).optional() +}) + +export const ActivateTab = WorktreeTabSelector.extend({ + tabId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing tab id')), + leafId: z.string().max(128).optional() +}) + +export type TerminalPaneLayoutNodeInput = + | { type: 'leaf'; leafId: string } + | { + type: 'split' + direction: 'horizontal' | 'vertical' + first: TerminalPaneLayoutNodeInput + second: TerminalPaneLayoutNodeInput + ratio?: number + } + +// Why: this schema parses UNTRUSTED remote-client input. A recursive zod parse +// of a deeply-nested tree would overflow the main-process stack, so validate +// iteratively with hard depth + node-count caps before building the typed value. +const MAX_PANE_LAYOUT_DEPTH = 64 +const MAX_PANE_LAYOUT_NODES = 1024 + +function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInput | null { + // Iterative validate-then-build: first walk the raw tree with an explicit + // stack (no recursion) enforcing caps, then build bottom-up. + let nodeCount = 0 + const stack: { raw: unknown; depth: number }[] = [{ raw: value, depth: 0 }] + while (stack.length > 0) { + const { raw, depth } = stack.pop()! + if (depth > MAX_PANE_LAYOUT_DEPTH || ++nodeCount > MAX_PANE_LAYOUT_NODES) { + return null + } + if (typeof raw !== 'object' || raw === null) { + return null + } + const node = raw as Record + if (node.type === 'leaf') { + if (typeof node.leafId !== 'string' || node.leafId.length < 1 || node.leafId.length > 128) { + return null + } + continue + } + if (node.type === 'split') { + if (node.direction !== 'horizontal' && node.direction !== 'vertical') { + return null + } + if ( + node.ratio !== undefined && + (typeof node.ratio !== 'number' || node.ratio < 0 || node.ratio > 1) + ) { + return null + } + stack.push({ raw: node.first, depth: depth + 1 }, { raw: node.second, depth: depth + 1 }) + continue + } + return null + } + return value as TerminalPaneLayoutNodeInput +} + +const TerminalPaneLayoutNodeSchema = z + .unknown() + .transform((value) => parseTerminalPaneLayoutNode(value)) + .pipe( + z.custom((value) => value !== null, { + message: 'Invalid or too-deep pane layout tree' + }) + ) + +export const UpdatePaneLayout = WorktreeTabSelector.extend({ + tabId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing tab id')), + root: z.union([z.null(), TerminalPaneLayoutNodeSchema]), + expandedLeafId: z.string().max(128).nullable().optional(), + titlesByLeafId: z.record(z.string(), z.string()).optional() +}) + +export const SetTabProps = WorktreeTabSelector.extend({ + tabId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing tab id')), + // undefined = leave unchanged; null = clear color / unset. + color: z.string().max(64).nullable().optional(), + isPinned: z.boolean().optional() +}) + +export const CreateTerminalTab = WorktreeTabSelector.extend({ + afterTabId: z.string().optional(), + targetGroupId: z.string().optional(), + command: z.string().optional(), + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), + agent: z + .custom(isTuiAgent, { + message: 'Unknown agent preset' + }) + .optional(), + activate: z.boolean().optional() +}) + +const MoveTabBase = { + worktree: WorktreeTabSelector.shape.worktree, + tabId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing tab id')), + targetGroupId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing target group id')) +} as const + +export const MoveTab = z.discriminatedUnion('kind', [ + z + .object({ + ...MoveTabBase, + kind: z.literal('reorder'), + tabOrder: z.array(z.string().min(1)).min(1, 'Missing tab order') + }) + .strict(), + z + .object({ + ...MoveTabBase, + kind: z.literal('move-to-group'), + index: z.number().int().nonnegative().optional() + }) + .strict(), + z + .object({ + ...MoveTabBase, + kind: z.literal('split'), + splitDirection: z.enum(['left', 'right', 'up', 'down']) + }) + .strict() +]) + +export const SaveMarkdownTab = ActivateTab.extend({ + baseVersion: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing base version')), + content: z.string() +}) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 19eadfd4887..28354741e34 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -1,83 +1,15 @@ import { z } from 'zod' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../../shared/types' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' - -const WorktreeTabSelector = z.object({ - worktree: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree selector')) -}) - -const SessionTabsUnsubscribe = WorktreeTabSelector.extend({ - subscriptionId: z.string().min(1).optional() -}) - -const ActivateTab = WorktreeTabSelector.extend({ - tabId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing tab id')), - leafId: z.string().max(128).optional() -}) - -const CreateTerminalTab = WorktreeTabSelector.extend({ - afterTabId: z.string().optional(), - targetGroupId: z.string().optional(), - command: z.string().optional(), - startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), - agent: z - .custom(isTuiAgent, { - message: 'Unknown agent preset' - }) - .optional(), - activate: z.boolean().optional() -}) - -const MoveTabBase = { - worktree: WorktreeTabSelector.shape.worktree, - tabId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing tab id')), - targetGroupId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing target group id')) -} as const - -const MoveTab = z.discriminatedUnion('kind', [ - z - .object({ - ...MoveTabBase, - kind: z.literal('reorder'), - tabOrder: z.array(z.string().min(1)).min(1, 'Missing tab order') - }) - .strict(), - z - .object({ - ...MoveTabBase, - kind: z.literal('move-to-group'), - index: z.number().int().nonnegative().optional() - }) - .strict(), - z - .object({ - ...MoveTabBase, - kind: z.literal('split'), - splitDirection: z.enum(['left', 'right', 'up', 'down']) - }) - .strict() -]) - -const SaveMarkdownTab = ActivateTab.extend({ - baseVersion: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing base version')), - content: z.string() -}) +import { + ActivateTab, + CreateTerminalTab, + MoveTab, + SaveMarkdownTab, + SessionTabsUnsubscribe, + SetTabProps, + UpdatePaneLayout, + WorktreeTabSelector +} from './session-tabs-schemas' export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ @@ -146,6 +78,27 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ }) } }), + defineMethod({ + name: 'session.tabs.updatePaneLayout', + params: UpdatePaneLayout, + handler: async (params, { runtime }) => + runtime.updateMobileSessionPaneLayout(params.worktree, { + tabId: params.tabId, + root: params.root, + expandedLeafId: params.expandedLeafId ?? null, + titlesByLeafId: params.titlesByLeafId + }) + }), + defineMethod({ + name: 'session.tabs.setTabProps', + params: SetTabProps, + handler: async (params, { runtime }) => + runtime.setMobileSessionTabProps(params.worktree, { + tabId: params.tabId, + ...(params.color !== undefined ? { color: params.color } : {}), + ...(params.isPinned !== undefined ? { isPinned: params.isPinned } : {}) + }) + }), defineStreamingMethod({ name: 'session.tabs.subscribe', params: WorktreeTabSelector, diff --git a/src/main/startup/ensure-virtual-display.test.ts b/src/main/startup/ensure-virtual-display.test.ts new file mode 100644 index 00000000000..e0a8b91781f --- /dev/null +++ b/src/main/startup/ensure-virtual-display.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock, spawnSyncMock, existsSyncMock, readFileSyncMock, rmSyncMock, appMock } = + vi.hoisted(() => ({ + spawnMock: vi.fn(), + spawnSyncMock: vi.fn(), + existsSyncMock: vi.fn(), + readFileSyncMock: vi.fn(), + rmSyncMock: vi.fn(), + appMock: { + disableHardwareAcceleration: vi.fn(), + commandLine: { appendSwitch: vi.fn() }, + once: vi.fn() + } + })) + +vi.mock('child_process', () => ({ spawn: spawnMock, spawnSync: spawnSyncMock })) +vi.mock('fs', () => ({ + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + rmSync: rmSyncMock +})) +vi.mock('electron', () => ({ app: appMock })) + +const ORIGINAL_PLATFORM = process.platform +const ORIGINAL_DISPLAY = process.env.DISPLAY + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +describe('ensureVirtualDisplayForHeadlessServe', () => { + beforeEach(() => { + spawnMock.mockReset() + spawnSyncMock.mockReset() + existsSyncMock.mockReset() + readFileSyncMock.mockReset() + rmSyncMock.mockReset() + appMock.disableHardwareAcceleration.mockReset() + appMock.commandLine.appendSwitch.mockReset() + appMock.once.mockReset() + delete process.env.DISPLAY + }) + + afterEach(() => { + setPlatform(ORIGINAL_PLATFORM) + if (ORIGINAL_DISPLAY === undefined) { + delete process.env.DISPLAY + } else { + process.env.DISPLAY = ORIGINAL_DISPLAY + } + }) + + it('is a no-op (supported) on non-Linux platforms', async () => { + setPlatform('darwin') + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('does not start a display outside serve mode on Linux', async () => { + setPlatform('linux') + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + + // Desktop Linux (non-serve) is reported unsupported for the offscreen path + // here, and never spawns Xvfb. + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: false })).toBe(false) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('reuses an externally provided DISPLAY without starting Xvfb', async () => { + setPlatform('linux') + process.env.DISPLAY = ':0' + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) + expect(spawnMock).not.toHaveBeenCalled() + expect(process.env.DISPLAY).toBe(':0') + }) + + it('reports unsupported (no spawn) when Xvfb is not installed', async () => { + setPlatform('linux') + spawnSyncMock.mockReturnValue({ status: 1 }) // `which Xvfb` fails + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(false) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('starts Xvfb and switches to software rendering when none exists', async () => { + setPlatform('linux') + spawnSyncMock.mockReturnValue({ status: 0 }) // `which Xvfb` succeeds + // First existsSync (stale-socket check) false; later (socket-ready poll) true. + existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true) + spawnMock.mockReturnValue({ once: vi.fn(), kill: vi.fn(), killed: false }) + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) + expect(spawnMock).toHaveBeenCalledWith( + 'Xvfb', + expect.arrayContaining([':99']), + expect.anything() + ) + expect(process.env.DISPLAY).toBe(':99') + expect(appMock.disableHardwareAcceleration).toHaveBeenCalled() + expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu') + }) + + it('reuses an existing virtual display only when its X server is alive', async () => { + setPlatform('linux') + spawnSyncMock.mockReturnValue({ status: 0 }) + existsSyncMock.mockReturnValue(true) // :99 socket + lock present + readFileSyncMock.mockReturnValue('4321\n') // lock holds a PID + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never) // PID alive + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) + expect(killSpy).toHaveBeenCalledWith(4321, 0) + expect(spawnMock).not.toHaveBeenCalled() + expect(rmSyncMock).not.toHaveBeenCalled() + expect(process.env.DISPLAY).toBe(':99') + killSpy.mockRestore() + }) + + it('treats a stale socket (dead server) as no display and starts a fresh Xvfb', async () => { + setPlatform('linux') + spawnSyncMock.mockReturnValue({ status: 0 }) + existsSyncMock.mockReturnValue(true) // orphan socket + lock present + readFileSyncMock.mockReturnValue('9999\n') + // PID is gone: process.kill throws ESRCH. + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('ESRCH') + }) + spawnMock.mockReturnValue({ once: vi.fn(), kill: vi.fn(), killed: false }) + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) + // Stale artifacts cleaned, then a fresh server started. + expect(rmSyncMock).toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledWith( + 'Xvfb', + expect.arrayContaining([':99']), + expect.anything() + ) + expect(process.env.DISPLAY).toBe(':99') + killSpy.mockRestore() + }) +}) diff --git a/src/main/startup/ensure-virtual-display.ts b/src/main/startup/ensure-virtual-display.ts new file mode 100644 index 00000000000..44488d02418 --- /dev/null +++ b/src/main/startup/ensure-virtual-display.ts @@ -0,0 +1,173 @@ +import { spawn, spawnSync, type ChildProcess } from 'child_process' +import { existsSync, readFileSync, rmSync } from 'fs' +import { app } from 'electron' + +// Why: headless `orca serve` backs browser panes with offscreen BrowserWindows. +// On Linux, Electron has no display platform without an X server and segfaults +// when such a window loads a page (verified: --headless/--ozone-platform=headless +// also crash; only a virtual display works). So before app.whenReady, ensure a +// virtual X display via Xvfb when none is present. macOS/Windows need nothing. + +const XVFB_STARTUP_TIMEOUT_MS = 5_000 +const XVFB_POLL_INTERVAL_MS = 50 +const VIRTUAL_DISPLAY_NUMBER = 99 +const VIRTUAL_DISPLAY = `:${VIRTUAL_DISPLAY_NUMBER}` + +let xvfbProcess: ChildProcess | null = null + +function xvfbSocketPath(displayNumber: number): string { + return `/tmp/.X11-unix/X${displayNumber}` +} + +function xDisplayLockPath(displayNumber: number): string { + return `/tmp/.X${displayNumber}-lock` +} + +// Why: a socket file can outlive the X server that made it. The X lock file holds +// the server PID; if that process is gone, the display is dead despite the socket. +function isDisplayServerAlive(displayNumber: number): boolean { + const lockPath = xDisplayLockPath(displayNumber) + if (!existsSync(lockPath)) { + // No lock means no server claimed this display; the bare socket is stale. + return false + } + let pid: number + try { + pid = Number.parseInt(readFileSync(lockPath, 'utf8').trim(), 10) + } catch { + return false + } + if (!Number.isInteger(pid) || pid <= 0) { + return false + } + try { + // signal 0 probes existence without affecting the process. + process.kill(pid, 0) + return true + } catch { + return false + } +} + +function removeStaleDisplayArtifacts(displayNumber: number): void { + for (const path of [xDisplayLockPath(displayNumber), xvfbSocketPath(displayNumber)]) { + try { + rmSync(path, { force: true }) + } catch { + // Best effort; if removal fails, Xvfb startup below will surface the error. + } + } +} + +function hasXvfbBinary(): boolean { + // Why: spawnSync `which` is cheap and avoids spawning Xvfb only to fail; a + // clear up-front warning beats a cryptic ENOENT mid-startup. + const result = spawnSync('which', ['Xvfb'], { stdio: 'ignore' }) + return result.status === 0 +} + +function sleepSync(ms: number): void { + // Why: this runs in the synchronous pre-whenReady startup path, so block + // without spinning the CPU or spawning a process. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) +} + +function waitForDisplaySocket(displayNumber: number, deadline: number): boolean { + const socket = xvfbSocketPath(displayNumber) + // Why: Xvfb creates its socket asynchronously after spawn; Electron must not + // boot before it exists or display init still fails. + while (Date.now() < deadline) { + if (existsSync(socket)) { + return true + } + sleepSync(XVFB_POLL_INTERVAL_MS) + } + return existsSync(socket) +} + +/** + * Ensure a usable X display for headless Linux serve. Returns true when a + * display is available (pre-existing or freshly started), false when browser + * panes cannot be supported on this host. Safe to call on any platform. + */ +export function ensureVirtualDisplayForHeadlessServe(options: { isServeMode: boolean }): boolean { + if (!options.isServeMode || process.platform !== 'linux') { + return process.platform !== 'linux' + } + + // Why: respect an externally provided display (a real X server, or the image + // already running its own Xvfb). Don't start a competing one. + if (process.env.DISPLAY && process.env.DISPLAY.trim().length > 0) { + return true + } + + if (!hasXvfbBinary()) { + console.warn( + '[serve] Xvfb not found; browser panes are unavailable on this headless Linux host. ' + + 'Install Xvfb (e.g. `apt-get install xvfb`) or set DISPLAY to enable them.' + ) + return false + } + + // Why: reuse an existing display ONLY if a live X server actually backs it. + // A crashed prior run can leave an orphan socket; trusting it by path alone + // would advertise browser support that then fails at tab creation. + if (existsSync(xvfbSocketPath(VIRTUAL_DISPLAY_NUMBER))) { + if (isDisplayServerAlive(VIRTUAL_DISPLAY_NUMBER)) { + process.env.DISPLAY = VIRTUAL_DISPLAY + return true + } + // Why: stale socket/lock — clean them up so Xvfb can rebind the display + // below instead of refusing to start on an "in use" number. + removeStaleDisplayArtifacts(VIRTUAL_DISPLAY_NUMBER) + } + + try { + xvfbProcess = spawn( + 'Xvfb', + [VIRTUAL_DISPLAY, '-screen', '0', '1280x1024x24', '-nolisten', 'tcp'], + { + stdio: 'ignore', + detached: false + } + ) + xvfbProcess.once('error', (error) => { + console.warn('[serve] Xvfb failed to start:', error instanceof Error ? error.message : error) + }) + } catch (error) { + console.warn( + '[serve] Could not start Xvfb:', + error instanceof Error ? error.message : String(error) + ) + return false + } + + const ready = waitForDisplaySocket(VIRTUAL_DISPLAY_NUMBER, Date.now() + XVFB_STARTUP_TIMEOUT_MS) + if (!ready) { + console.warn('[serve] Xvfb did not become ready in time; browser panes may be unavailable.') + stopVirtualDisplay() + return false + } + + process.env.DISPLAY = VIRTUAL_DISPLAY + // Why: the offscreen browser must use software rendering; a virtual display has + // no GPU. Must be set before app.whenReady (this runs at module load). + app.disableHardwareAcceleration() + app.commandLine.appendSwitch('disable-gpu') + + // Why: don't leave a stray Xvfb process behind when serve exits. + app.once('will-quit', stopVirtualDisplay) + + return true +} + +export function stopVirtualDisplay(): void { + if (xvfbProcess && !xvfbProcess.killed) { + try { + xvfbProcess.kill() + } catch { + // already exiting + } + } + xvfbProcess = null +} diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index d20e81bea64..e1b2919533c 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -1144,6 +1144,11 @@ function RemoteBrowserPagePane({ ) useEffect(() => { + // Why: StrictMode (and any real remount) runs mount→cleanup→mount. The + // cleanup sets mountedRef false; without re-arming it on mount, every + // subsequent operation token reads as stale (isCurrentRemoteOperationToken + // gates on mountedRef) and the pane wedges on "Opening remote browser". + mountedRef.current = true return () => { mountedRef.current = false remoteOperationGenerationRef.current += 1 @@ -1173,9 +1178,11 @@ function RemoteBrowserPagePane({ }, [clearPendingRemoteWheel]) useEffect(() => { - remoteOperationGenerationRef.current += 1 - streamGenerationRef.current += 1 - activeStreamTokenRef.current = null + // Why: only reset the visible frame/wheel when the pane's identity changes. + // The stream/operation generations are owned solely by the streaming effect + // below — bumping them here too races that effect (e.g. under StrictMode's + // mount→cleanup→mount), leaving its captured token permanently one behind so + // the pane wedges on "Opening remote browser" while frames are available. remoteStreamViewportSizeRef.current = null clearPendingRemoteWheel() clearStreamFrame() diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts index 076cbd7da5c..c3e1db6e295 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts @@ -13,6 +13,7 @@ import { getHostDetailsSummary, getHostModelCapabilitySummary, getRuntimeCapabilitiesSummary, + getRuntimeServerConnectionState, type RuntimeHostDetails } from './RuntimeEnvironmentsPane' @@ -174,6 +175,33 @@ describe('RuntimeEnvironmentsPane host details', () => { ).toBe('Host model support: update server for task source context, workspace run context') }) + it('reports an attached, ready, compatible host as Connected regardless of active-ness', () => { + // Why: the row tracks attachment (reachable + ready), which exposes Disconnect. + // Whether the host is the default *active* server is a separate concept, so it + // must NOT change this label — otherwise the dot/label/button disagree (a host + // showed "Available" with a grey dot yet offered Disconnect). + expect(getRuntimeServerConnectionState(details({ status: 'ready' }))).toBe('connected') + expect(getRuntimeServerConnectionState(undefined)).toBe('checking') + expect(getRuntimeServerConnectionState(details({ status: 'loading' }))).toBe('checking') + expect(getRuntimeServerConnectionState(details({ status: 'error', error: 'offline' }))).toBe( + 'disconnected' + ) + expect( + getRuntimeServerConnectionState( + details({ + status: 'ready', + compatibility: { + kind: 'blocked', + reason: 'server-too-old', + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + serverProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1, + requiredServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION + } + }) + ) + ).toBe('disconnected') + }) + it('explains that selecting a saved server is the explicit default Host mode', () => { expect(getActiveServerModeDescription(true)).toContain('Use this computer by default') expect(getActiveServerModeDescription(true)).toContain('browser/mobile handoff') diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index e718fed3f64..3cde8dbf03c 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -192,9 +192,8 @@ export function getActiveServerModeDescription(allowLocalRuntime: boolean): stri type RuntimeServerConnectionState = 'connected' | 'checking' | 'disconnected' -function getRuntimeServerConnectionState( - details: RuntimeHostDetails | undefined, - _active: boolean +export function getRuntimeServerConnectionState( + details: RuntimeHostDetails | undefined ): RuntimeServerConnectionState { if (!details || details.status === 'loading') { return 'checking' @@ -202,6 +201,10 @@ function getRuntimeServerConnectionState( if (details.status !== 'ready' || details.compatibility?.kind === 'blocked') { return 'disconnected' } + // Why: an attached, reachable, compatible host is "Connected" (and exposes + // Disconnect). Whether it is the default *active* server is a separate concept, + // surfaced by the Advanced > Active Server selector and the row's help text — + // it must not change this connection label, or the dot/label/button disagree. return 'connected' } @@ -864,7 +867,8 @@ export function RuntimeEnvironmentsPane({ const details = detailsByEnvironmentId[environment.id] const detailsDescription = getHostDetailsDescription(details) const isActive = settings.activeRuntimeEnvironmentId === environment.id - const connectionState = getRuntimeServerConnectionState(details, isActive) + const connectionState = getRuntimeServerConnectionState(details) + // A connected host exposes Disconnect; otherwise Connect. const isReachable = connectionState === 'connected' const actionBusy = connectingId === environment.id || diff --git a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx index 681262a8fcf..64e61f6a532 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx @@ -13,6 +13,7 @@ const updateWorktreeMeta = vi.fn() let WorktreeCard: typeof WorktreeCardComponent let sshConnectionStates = new Map() let sshTargetLabels = new Map() +let runtimeStatusByEnvironmentId = new Map() let worktreeCardProperties: WorktreeCardProperty[] = ['status'] vi.mock('@/store', () => ({ @@ -28,6 +29,7 @@ vi.mock('@/store', () => ({ linearIssueCache: {}, openModal, remoteBranchConflictByWorktreeId: {}, + runtimeStatusByEnvironmentId, settings: null, sshConnectionStates, sshTargetLabels, @@ -126,6 +128,7 @@ describe('WorktreeCard SSH reconnect prompt', () => { vi.clearAllMocks() sshConnectionStates = new Map() sshTargetLabels = new Map() + runtimeStatusByEnvironmentId = new Map() worktreeCardProperties = ['status'] }) @@ -141,4 +144,31 @@ describe('WorktreeCard SSH reconnect prompt', () => { expect(markup).toContain('data-ssh-status="disconnected"') expect(markup).toContain('data-ssh-target-label="Remote target"') }) + + it('marks a runtime-host worktree disconnected when its environment has no status', () => { + const runtimeRepo: Repo = { + ...makeRepo(), + connectionId: undefined, + executionHostId: 'runtime:env-1' + } + // No status entry for env-1 → host is disconnected. + const markup = renderToStaticMarkup( + + ) + expect(markup).toContain('Server disconnected') + }) + + it('shows a runtime-host worktree as connected when its environment has a status', () => { + runtimeStatusByEnvironmentId.set('env-1', { status: { runtimeId: 'r1' } }) + const runtimeRepo: Repo = { + ...makeRepo(), + connectionId: undefined, + executionHostId: 'runtime:env-1' + } + const markup = renderToStaticMarkup( + + ) + expect(markup).not.toContain('Server disconnected') + expect(markup).toContain('Project on Orca server') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 2652df00f07..6a3c331febf 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -66,6 +66,7 @@ import { getFlushWorktreeCardPaddingLeft } from './worktree-list-indentation' import { translate } from '@/i18n/i18n' import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { parseExecutionHostId } from '../../../../shared/execution-host' type WorktreeRenameRequest = { worktreeId: string @@ -315,6 +316,17 @@ const WorktreeCard = React.memo(function WorktreeCard({ return state?.status ?? 'disconnected' }) const isSshDisconnected = sshStatus != null && sshStatus !== 'connected' + + // Why: runtime ("Orca server") hosts get the same disconnected treatment as + // SSH — when the host's runtime environment has no live status, its worktrees + // are dimmed and marked disconnected instead of looking fully available. + const isRuntimeDisconnected = useAppStore((s) => { + const parsed = parseExecutionHostId(repo?.executionHostId) + if (parsed?.kind !== 'runtime') { + return false + } + return !s.runtimeStatusByEnvironmentId.get(parsed.environmentId)?.status + }) const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false) const sshDisconnectedPromptKey = isActive && isSshDisconnected ? worktree.id : null const [lastSshDisconnectedPromptKey, setLastSshDisconnectedPromptKey] = useState( @@ -1263,6 +1275,32 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} + {!repo?.connectionId && + parseExecutionHostId(repo?.executionHostId)?.kind === 'runtime' && ( + + + + {isRuntimeDisconnected ? ( + + ) : ( + + )} + + + + {isRuntimeDisconnected + ? translate( + 'auto.components.sidebar.WorktreeCard.runtimeHostDisconnected', + 'Server disconnected' + ) + : translate( + 'auto.components.sidebar.WorktreeCard.runtimeHostProject', + 'Project on Orca server' + )} + + + )} + {showInlineRepoBadge && ( s.runtimeStatusByEnvironmentId) + const fetchWorktreeLineage = useAppStore((s) => s.fetchWorktreeLineage) + const onlineRuntimeEnvKey = React.useMemo( + () => + // Why: tolerate an absent map — a partial/hydrating store can leave this + // undefined, and a thrown selector would crash the whole sidebar render. + [...(runtimeStatusByEnvironmentId?.entries() ?? [])] + .filter(([, entry]) => Boolean(entry?.status)) + .map(([id]) => id) + .sort() + .join(','), + [runtimeStatusByEnvironmentId] + ) + const previousOnlineRuntimeEnvKeyRef = React.useRef(null) + useEffect(() => { + // Skip the initial value — startup/repoCount effects already fetch. Only + // refetch when the online-host set actually changes. + if (previousOnlineRuntimeEnvKeyRef.current === null) { + previousOnlineRuntimeEnvKeyRef.current = onlineRuntimeEnvKey + return + } + if (previousOnlineRuntimeEnvKeyRef.current === onlineRuntimeEnvKey) { + return + } + previousOnlineRuntimeEnvKeyRef.current = onlineRuntimeEnvKey + void fetchAllWorktrees().then(() => fetchWorktreeLineage()) + }, [onlineRuntimeEnvKey, fetchAllWorktrees, fetchWorktreeLineage]) + useEffect(() => { if (!sidebarOpen && workspaceBoardRenderedOpen) { closeWorkspaceBoard() diff --git a/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx b/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx index 67aa1b23535..3b5019f4719 100644 --- a/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx +++ b/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx @@ -3,19 +3,12 @@ import { Loader2 } from 'lucide-react' import { translate } from '@/i18n/i18n' import { useMountedRef } from '@/hooks/useMountedRef' -export type RuntimeHostConnectionState = - | 'connected' - | 'available' - | 'checking' - | 'reconnecting' - | 'disconnected' +export type RuntimeHostConnectionState = 'connected' | 'checking' | 'reconnecting' | 'disconnected' function runtimeStatusLabel(state: RuntimeHostConnectionState): string { switch (state) { case 'connected': return translate('auto.components.status.bar.SshStatusSegment.runtime_online', 'Connected') - case 'available': - return translate('auto.components.status.bar.SshStatusSegment.runtime_available', 'Available') case 'checking': return translate('auto.components.status.bar.SshStatusSegment.runtime_checking', 'Checking') case 'reconnecting': @@ -38,7 +31,6 @@ function runtimeDotColor(state: RuntimeHostConnectionState): string { case 'checking': case 'reconnecting': return 'bg-yellow-500' - case 'available': case 'disconnected': return 'bg-muted-foreground/40' } @@ -55,7 +47,6 @@ function runtimeActionLabel(state: RuntimeHostConnectionState): string | null { switch (state) { case 'connected': return translate('auto.components.status.bar.SshStatusSegment.59b553e2aa', 'Disconnect') - case 'available': case 'disconnected': return translate('auto.components.status.bar.SshStatusSegment.63f36455cc', 'Connect') case 'checking': diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.test.ts b/src/renderer/src/components/status-bar/SshStatusSegment.test.ts index 8e99ad82257..1e5db8431bf 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.test.ts +++ b/src/renderer/src/components/status-bar/SshStatusSegment.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest' import { isConnectedRuntimeHostState, runtimeStatusForOverall } from './SshStatusSegment' describe('SshStatusSegment host status helpers', () => { - it('counts available remote servers as connected hosts', () => { - expect(runtimeStatusForOverall('available')).toBe('connected') - expect(isConnectedRuntimeHostState('available')).toBe(true) + it('counts connected remote servers as connected hosts', () => { + // Why: "connected" = attached/reachable (active-agnostic), matching Settings. + // There is no separate "available" state — a reachable host is just Connected. + expect(runtimeStatusForOverall('connected')).toBe('connected') + expect(isConnectedRuntimeHostState('connected')).toBe(true) }) it('keeps reconnecting and disconnected remote servers out of the connected count', () => { diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index 7f436e1512d..6f3bd584486 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -71,12 +71,10 @@ function sshStatusForOverall(status: SshConnectionStatus): HostStatus { function runtimeHostConnectionState({ hasStatus, online, - active, remoteControl }: { hasStatus: boolean online: boolean - active: boolean remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null }): RuntimeHostConnectionState { if (!hasStatus) { @@ -91,7 +89,12 @@ function runtimeHostConnectionState({ if (remoteControl?.state === 'closed' && remoteControl.lastError) { return 'disconnected' } - return active ? 'connected' : 'available' + // Why: "connected" means attached/reachable, NOT "is the active default host". + // Both surfaces (this status bar and Settings > Remote Orca Servers) must agree + // on that single definition, or a reachable-but-not-active host reads + // "Connected" in one place and "Available" in the other. Active/default is a + // separate concept (surfaced elsewhere), so it must not change this state. + return 'connected' } function runtimeHostConnectionDetail( @@ -117,23 +120,16 @@ function runtimeHostConnectionDetail( { value0: String(remoteControl.reconnectAttempt + 1) } ) } - if (remoteControl.pendingRequestCount > 0 || remoteControl.subscriptionCount > 0) { - return translate( - 'auto.components.status.bar.SshStatusSegment.runtime_channel_counts', - '{{value0}} pending · {{value1}} streams', - { - value0: String(remoteControl.pendingRequestCount), - value1: String(remoteControl.subscriptionCount) - } - ) - } + // Why: pending-request / subscription counts are internal RPC plumbing (e.g. a + // live browser screencast shows as "N streams"). They're noise in a user-facing + // status row and make the line truncate — only surface actionable detail + // (errors, close reasons, reconnect attempts) above. return undefined } export function runtimeStatusForOverall(state: RuntimeHostConnectionState): HostStatus { switch (state) { case 'connected': - case 'available': return 'connected' case 'checking': case 'reconnecting': @@ -144,7 +140,7 @@ export function runtimeStatusForOverall(state: RuntimeHostConnectionState): Host } export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): boolean { - return state === 'connected' || state === 'available' + return state === 'connected' } export function SshStatusSegment({ diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 1619f237ac2..5f5982f4968 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -961,6 +961,7 @@ function TabBarInner({ ) const togglePinned = (item: TabItem): void => { + // pinTab/unpinTab mirror the change to the host for remote-server tabs. if (item.isPinned) { unpinTab(item.unifiedTabId) return diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts index 2bab378f649..d5d0c908717 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts @@ -299,4 +299,57 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => { ) expect(mocks.closeBrowserTab).toHaveBeenCalledWith('browser-workspace-1') }) + + it('closes a host-mirrored browser with an empty page list via the host (no dead-end)', async () => { + // Regression: a host-owned browser whose local page list was momentarily + // empty had no remote-owned PAGES, so the close skipped the host RPC and the + // local close couldn't resolve it — the tab became un-closable. It must now + // route to the host close AND remove the visible unified tab. + mocks.isWebRuntimeSessionActive.mockReturnValue(true) + storeBox.state = { + ...storeBox.state, + // No pages for this workspace — the corrupt/transient state. + browserPagesByWorkspace: {}, + browserTabsByWorktree: { 'wt-1': [] }, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-1', + worktreeId: 'wt-1', + activeTabId: 'browser-unified-1', + tabOrder: ['browser-unified-1'] + } + ] + }, + remoteBrowserPageHandlesByPageId: {}, + settings: { activeRuntimeEnvironmentId: 'remote-runtime' }, + tabsByWorktree: { 'wt-1': [] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'browser-unified-1', + entityId: 'browser-workspace-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'browser', + label: 'New Browser Tab', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + const { useTabGroupWorkspaceModel } = await import('./useTabGroupWorkspaceModel') + const model = useTabGroupWorkspaceModel({ groupId: 'group-1', worktreeId: 'wt-1' }) + + model.commands.closeItem('browser-unified-1') + + // Host close fires (idempotent) and the visible unified tab is removed. + expect(mocks.closeWebRuntimeSessionTab).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'wt-1', tabId: 'browser-unified-1' }) + ) + expect(mocks.closeUnifiedTab).toHaveBeenCalledWith('browser-unified-1') + }) }) diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 75461305024..5cc355b55ac 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -252,23 +252,30 @@ export function useTabGroupWorkspaceModel({ } return } - if ( - item.contentType === 'browser' && - isWebRuntimeSessionActive(runtimeEnvironmentId) && - browserWorkspaceHasRemoteOwner(useAppStore.getState(), item.entityId, runtimeEnvironmentId) - ) { - // Why: paired web clients mirror host-owned tabs. Closing locally races - // the host session snapshot and leaves stale terminal/browser handles. - void closeWebRuntimeSessionTab({ - worktreeId, - tabId: item.id, - environmentId: runtimeEnvironmentId - }) - return - } if (item.contentType === 'browser') { - destroyWorkspaceWebviews(useAppStore.getState().browserPagesByWorkspace, item.entityId) + const browserState = useAppStore.getState() + const hasLocalPages = (browserState.browserPagesByWorkspace[item.entityId] ?? []).length > 0 + // Why: ask the host to close (idempotent) for a remote-owned browser, OR + // for a host-mirror whose local page list is momentarily empty — that + // pageless case was the bug: it has no remote-owned PAGES so the old gate + // skipped the host close AND the local close couldn't resolve it, leaving + // the tab un-closable. A genuine client-local fallback browser always has + // local pages, so it stays local. Always run local cleanup so the visible + // tab is removed no matter what. + const shouldCloseOnHost = + isWebRuntimeSessionActive(runtimeEnvironmentId) && + (browserWorkspaceHasRemoteOwner(browserState, item.entityId, runtimeEnvironmentId) || + !hasLocalPages) + if (shouldCloseOnHost) { + void closeWebRuntimeSessionTab({ + worktreeId, + tabId: item.id, + environmentId: runtimeEnvironmentId + }) + } + destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId) closeBrowserTab(item.entityId) + closeUnifiedTab(item.id) } else if (item.contentType === 'simulator') { closeUnifiedTab(item.id) } else { @@ -303,28 +310,37 @@ export function useTabGroupWorkspaceModel({ useAppStore.getState(), worktreeId ) - if ( - (item.contentType === 'terminal' || - (item.contentType === 'browser' && - browserWorkspaceHasRemoteOwner( - useAppStore.getState(), - item.entityId, - runtimeEnvironmentId - ))) && - isWebRuntimeSessionActive(runtimeEnvironmentId) - ) { + if (item.contentType === 'terminal' && isWebRuntimeSessionActive(runtimeEnvironmentId)) { void closeWebRuntimeSessionTab({ worktreeId, - tabId: item.contentType === 'browser' ? item.id : item.entityId, + tabId: item.entityId, environmentId: runtimeEnvironmentId }) continue } - if (item.contentType === 'terminal') { - closeTab(item.entityId) - } else if (item.contentType === 'browser') { - destroyWorkspaceWebviews(useAppStore.getState().browserPagesByWorkspace, item.entityId) + if (item.contentType === 'browser') { + // Why: see closeItem — host-close a remote-owned browser OR a pageless + // host-mirror (the dead-end case), keep genuine local fallbacks local, + // and always remove the visible tab. + const browserState = useAppStore.getState() + const hasLocalPages = + (browserState.browserPagesByWorkspace[item.entityId] ?? []).length > 0 + const shouldCloseOnHost = + isWebRuntimeSessionActive(runtimeEnvironmentId) && + (browserWorkspaceHasRemoteOwner(browserState, item.entityId, runtimeEnvironmentId) || + !hasLocalPages) + if (shouldCloseOnHost) { + void closeWebRuntimeSessionTab({ + worktreeId, + tabId: item.id, + environmentId: runtimeEnvironmentId + }) + } + destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId) closeBrowserTab(item.entityId) + closeUnifiedTab(item.id) + } else if (item.contentType === 'terminal') { + closeTab(item.entityId) } else if (item.contentType === 'simulator') { closeUnifiedTab(item.id) } else { diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 1b385056daa..7488699e736 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -73,8 +73,15 @@ import { import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture' -import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' -import { closeWebRuntimeTerminal } from '@/runtime/web-runtime-session' +import { + inspectRuntimeTerminalProcess, + isRemoteRuntimePtyId +} from '@/runtime/runtime-terminal-inspection' +import { + clearWebRuntimeTerminalBuffer, + closeWebRuntimeTerminal, + updateWebRuntimePaneLayout +} from '@/runtime/web-runtime-session' import { isPrimarySelectionEnabled, readPrimarySelectionText } from '@/lib/primary-selection' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' import { isTerminalSessionStateSaveFailure } from '../../../../shared/terminal-session-state-save-failure' @@ -82,7 +89,10 @@ import { isSyntheticSinglePaneTitle, sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' -import { planTerminalLiveLayoutInsertions } from './terminal-live-layout-reconciliation' +import { + isHostAuthoritativeLayout, + planTerminalLiveLayoutInsertions +} from './terminal-live-layout-reconciliation' import type { TerminalQuickCommand, TerminalQuickCommandScope } from '../../../../shared/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' @@ -673,18 +683,37 @@ export default function TerminalPane({ layout.titlesByLeafId = titlesByLeafId } setTabLayout(tabId, layout) + // Why: pane geometry is host-authoritative for remote-server tabs, so push + // ratios/expand/titles to the host or they revert on the next snapshot. + // Gate on a remote pty to avoid an RPC for purely-local tabs. + const hasRemotePane = Object.values(mergedPtyIds).some( + (ptyId) => typeof ptyId === 'string' && isRemoteRuntimePtyId(ptyId) + ) + if (hasRemotePane) { + void updateWebRuntimePaneLayout({ + worktreeId, + tabId, + root: layout.root, + expandedLeafId: layout.expandedLeafId, + ...(layout.titlesByLeafId ? { titlesByLeafId: layout.titlesByLeafId } : {}) + }) + } for (const leafId of currentLeafIds) { clearedScrollbackLeafIds.delete(leafId) } - }, [tabId, setTabLayout]) + }, [tabId, setTabLayout, worktreeId]) const clearPaneScrollback = useCallback( (pane: ManagedPane): void => { clearedScrollbackLeafIdsRef.current.add(pane.leafId) pane.terminal.clear() + // Why: also clear the host buffer for remote-server panes, or the next + // host snapshot replays the scrollback we just cleared locally. + const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null + clearWebRuntimeTerminalBuffer(ptyId) persistLayoutSnapshot() }, - [persistLayoutSnapshot] + [paneTransportsRef, persistLayoutSnapshot] ) useEffect(() => { @@ -948,13 +977,21 @@ export default function TerminalPane({ }) useEffect(() => { - if (!(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) { - return - } const manager = managerRef.current if (!manager || !restoredLayout.root) { return } + // Host-owned split layouts (web clients, or a desktop client viewing a + // remote server worktree) arrive via the host snapshot, so the reconciler + // must materialize their panes; local desktop tabs split directly. + if ( + !isHostAuthoritativeLayout({ + isWebClient: !!(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__, + ptyIdsByLeafId: restoredLayout.ptyIdsByLeafId + }) + ) { + return + } const insertions = planTerminalLiveLayoutInsertions( restoredLayout.root, manager.getPanes().map((pane) => pane.leafId) diff --git a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts index f67ca0fc697..50b51941701 100644 --- a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts @@ -1,7 +1,42 @@ import { describe, expect, it } from 'vitest' -import { planTerminalLiveLayoutInsertions } from './terminal-live-layout-reconciliation' +import { + isHostAuthoritativeLayout, + planTerminalLiveLayoutInsertions +} from './terminal-live-layout-reconciliation' import type { TerminalPaneLayoutNode } from '../../../../shared/types' +const LOCAL_PTY = 'pty-local-123' +const REMOTE_PTY = 'remote:env-1@@term_abc' + +describe('isHostAuthoritativeLayout', () => { + it('is true for any web client regardless of pty ids', () => { + expect(isHostAuthoritativeLayout({ isWebClient: true, ptyIdsByLeafId: { a: LOCAL_PTY } })).toBe( + true + ) + }) + + it('is true for a desktop client when a leaf has a remote-runtime pty (remote server tab)', () => { + // Why: the split-render bug — desktop viewing a remote server got the host + // layout but skipped reconciliation, so the split never rendered. + expect( + isHostAuthoritativeLayout({ + isWebClient: false, + ptyIdsByLeafId: { a: LOCAL_PTY, b: REMOTE_PTY } + }) + ).toBe(true) + }) + + it('is false for a desktop client with only local ptys (local tab splits directly)', () => { + expect( + isHostAuthoritativeLayout({ isWebClient: false, ptyIdsByLeafId: { a: LOCAL_PTY } }) + ).toBe(false) + }) + + it('is false for a desktop client with no pty ids', () => { + expect(isHostAuthoritativeLayout({ isWebClient: false, ptyIdsByLeafId: undefined })).toBe(false) + }) +}) + describe('planTerminalLiveLayoutInsertions', () => { it('plans a host-added split leaf from an already-mounted source leaf', () => { const layout: TerminalPaneLayoutNode = { diff --git a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts index 6173e803d93..152c90f82e1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts @@ -1,4 +1,24 @@ import type { TerminalPaneLayoutNode, TerminalPaneSplitDirection } from '../../../../shared/types' +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' + +/** + * Whether a tab's split layout is owned by a host (web/mobile clients, or a + * desktop client viewing a remote "Orca server" worktree) rather than built + * locally. Such layouts arrive via the host snapshot, so the live-layout + * reconciler must materialize their panes. A desktop client only needs this for + * remote-runtime tabs — local tabs split their panes directly. + */ +export function isHostAuthoritativeLayout(args: { + isWebClient: boolean + ptyIdsByLeafId: Record | undefined +}): boolean { + if (args.isWebClient) { + return true + } + return Object.values(args.ptyIdsByLeafId ?? {}).some( + (ptyId) => typeof ptyId === 'string' && isRemoteRuntimePtyId(ptyId) + ) +} export type TerminalLiveLayoutInsertion = { sourceLeafId: string diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 323a469fce0..35834388ed1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2671,13 +2671,11 @@ "runtime_checking": "Checking", "runtime_online": "Connected", "runtime_unavailable": "Disconnected", - "runtime_available": "Available", "runtime_connect_unavailable": "Remote host is not reachable", "runtime_disconnect_failed": "Disconnect failed", "runtime_reconnecting": "Reconnecting", "runtime_last_close_reason": "Closed: {{value0}}", - "runtime_reconnect_attempt": "Attempt {{value0}}", - "runtime_channel_counts": "{{value0}} pending · {{value1}} streams" + "runtime_reconnect_attempt": "Attempt {{value0}}" }, "StatusBar": { "9659e38343": "Ports", @@ -3750,7 +3748,9 @@ "93aebe4529": "Folder", "0d224eff10": "Primary worktree", "ca74db7550": "Project on SSH host", - "021538e1d1": "SSH disconnected" + "021538e1d1": "SSH disconnected", + "runtimeHostDisconnected": "Server disconnected", + "runtimeHostProject": "Project on Orca server" }, "WorktreeCardAgents": { "1b0a156717": "Agents" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 016e565aec1..d06e02b4dc0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2671,13 +2671,11 @@ "runtime_checking": "Checking", "runtime_online": "Connected", "runtime_unavailable": "Disconnected", - "runtime_available": "Available", "runtime_connect_unavailable": "Remote host is not reachable", "runtime_disconnect_failed": "Disconnect failed", "runtime_reconnecting": "Reconnecting", "runtime_last_close_reason": "Closed: {{value0}}", - "runtime_reconnect_attempt": "Attempt {{value0}}", - "runtime_channel_counts": "{{value0}} pending · {{value1}} streams" + "runtime_reconnect_attempt": "Attempt {{value0}}" }, "StatusBar": { "9659e38343": "Puertos", @@ -3736,7 +3734,9 @@ "93aebe4529": "Carpeta", "0d224eff10": "árbol de trabajo primario", "ca74db7550": "Proyecto remoto vía SSH", - "021538e1d1": "SSH desconectado" + "021538e1d1": "SSH desconectado", + "runtimeHostDisconnected": "Server disconnected", + "runtimeHostProject": "Project on Orca server" }, "WorktreeCardAgents": { "1b0a156717": "Agents" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8e379f8a78a..a9ede510629 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2671,13 +2671,11 @@ "runtime_checking": "確認中", "runtime_online": "接続済み", "runtime_unavailable": "Disconnected", - "runtime_available": "Available", "runtime_connect_unavailable": "Remote host is not reachable", "runtime_disconnect_failed": "Disconnect failed", "runtime_reconnecting": "Reconnecting", "runtime_last_close_reason": "Closed: {{value0}}", - "runtime_reconnect_attempt": "Attempt {{value0}}", - "runtime_channel_counts": "{{value0}} pending · {{value1}} streams" + "runtime_reconnect_attempt": "Attempt {{value0}}" }, "StatusBar": { "9659e38343": "ポート", @@ -3717,7 +3715,9 @@ "93aebe4529": "フォルダ", "0d224eff10": "プライマリワークツリー", "ca74db7550": "SSH 経由のリモートプロジェクト", - "021538e1d1": "SSH が切断されました" + "021538e1d1": "SSH が切断されました", + "runtimeHostDisconnected": "Server disconnected", + "runtimeHostProject": "Project on Orca server" }, "WorktreeCardAgents": { "1b0a156717": "Agents" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8654c105981..00bcbaa28ae 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2671,13 +2671,11 @@ "runtime_checking": "확인 중", "runtime_online": "연결됨", "runtime_unavailable": "Disconnected", - "runtime_available": "Available", "runtime_connect_unavailable": "Remote host is not reachable", "runtime_disconnect_failed": "Disconnect failed", "runtime_reconnecting": "Reconnecting", "runtime_last_close_reason": "Closed: {{value0}}", - "runtime_reconnect_attempt": "Attempt {{value0}}", - "runtime_channel_counts": "{{value0}} pending · {{value1}} streams" + "runtime_reconnect_attempt": "Attempt {{value0}}" }, "StatusBar": { "9659e38343": "포트", @@ -3717,7 +3715,9 @@ "93aebe4529": "접는 사람", "0d224eff10": "기본 작업 트리", "ca74db7550": "SSH를 통한 원격 프로젝트", - "021538e1d1": "SSH 연결이 끊어졌습니다." + "021538e1d1": "SSH 연결이 끊어졌습니다.", + "runtimeHostDisconnected": "Server disconnected", + "runtimeHostProject": "Project on Orca server" }, "WorktreeCardAgents": { "1b0a156717": "Agents" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 9525182f4bf..626dee61522 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2671,13 +2671,11 @@ "runtime_checking": "检查中", "runtime_online": "已连接", "runtime_unavailable": "已断开", - "runtime_available": "可用", "runtime_connect_unavailable": "远程主机不可达", "runtime_disconnect_failed": "断开连接失败", "runtime_reconnecting": "重新连接中", "runtime_last_close_reason": "Closed: {{value0}}", - "runtime_reconnect_attempt": "Attempt {{value0}}", - "runtime_channel_counts": "{{value0}} pending · {{value1}} streams" + "runtime_reconnect_attempt": "Attempt {{value0}}" }, "StatusBar": { "9659e38343": "端口", @@ -3717,7 +3715,9 @@ "93aebe4529": "文件夹", "0d224eff10": "主要工作树", "ca74db7550": "通过 SSH 远程项目", - "021538e1d1": "SSH 已断开连接" + "021538e1d1": "SSH 已断开连接", + "runtimeHostDisconnected": "Server disconnected", + "runtimeHostProject": "Project on Orca server" }, "WorktreeCardAgents": { "1b0a156717": "Agents" diff --git a/src/renderer/src/runtime/remote-server-parity.test.ts b/src/renderer/src/runtime/remote-server-parity.test.ts new file mode 100644 index 00000000000..6122838dc81 --- /dev/null +++ b/src/renderer/src/runtime/remote-server-parity.test.ts @@ -0,0 +1,436 @@ +/** + * Remote-server ↔ local parity tests. + * + * Maps to docs/remote-server-parity-test-plan.md. These assert the DETERMINISTIC + * behaviors of the remote-runtime session reconcile (the code that governs how + * remote workspaces order tabs, take focus, mark disconnect, and react to host + * state) so a remote workspace behaves 1:1 with a local one. + * + * Scope: the remote tab/session model is server-authoritative — the client + * reconciles host snapshots via applyWebSessionTabsSnapshot. This suite drives + * that reconcile directly. UI-heavy / two-host flows (splitting geometry, right + * sidebar rendering, live screencast, mobile) are covered by the live checklist, + * not here. + */ +import { beforeEach, describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import type { Tab } from '../../../shared/types' +import { + applyFreshWebSessionTabsSnapshot, + applyWebSessionTabsSnapshot, + resetWebSessionTabsSnapshotFreshnessForTests, + type WebSessionTabsSyncState +} from './web-session-tabs-sync' +import { + recordWebSessionFocusIntent, + resetWebSessionFocusIntentForTests +} from './web-session-focus-intent' + +const WT = 'repo::/worktree' +const ENV = 'web-env-1' +const NOW = 1_700_000_000_000 +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const LEAF_C = '33333333-3333-4333-8333-333333333333' + +function makeState(overrides: Partial = {}): WebSessionTabsSyncState { + return { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: {}, + activeTabId: null, + activeTabIdByWorktree: {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: {}, + activeWorktreeId: WT, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + openFiles: [], + ptyIdsByTabId: {}, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: {}, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: {}, + unreadTerminalTabs: {}, + sortEpoch: 0, + ...overrides + } +} + +type HostTerminal = { parentTab: string; leaf: string; active: boolean; title?: string } + +function terminalSnapshotTab(t: HostTerminal): RuntimeMobileSessionTabsResult['tabs'][number] { + return { + type: 'terminal', + id: `${t.parentTab}::${t.leaf}`, + title: t.title ?? t.parentTab, + parentTabId: t.parentTab, + leafId: t.leaf, + isActive: t.active, + status: 'ready', + terminal: `${t.parentTab}-pty` + } +} + +function makeSnapshot( + terminals: HostTerminal[], + overrides: Partial = {} +): RuntimeMobileSessionTabsResult { + const tabs = terminals.map(terminalSnapshotTab) + const active = terminals.find((t) => t.active) + return { + worktree: WT, + publicationEpoch: `epoch-${overrides.snapshotVersion ?? 1}`, + snapshotVersion: 1, + activeGroupId: 'host-group-1', + activeTabId: active ? `${active.parentTab}::${active.leaf}` : null, + activeTabType: active ? 'terminal' : null, + tabGroups: [ + { + id: 'host-group-1', + activeTabId: active?.parentTab ?? null, + tabOrder: terminals.map((t) => t.parentTab) + } + ], + tabs, + ...overrides + } +} + +// A local terminal tab + matching unified tab + group entry, as the store holds +// them, so a snapshot reconcile has a realistic prior state to compare against. +function localTerminal(parentTab: string, sortOrder: number, active: boolean) { + const id = toWebTerminalSurfaceTabId(parentTab) + const unified: Tab = { + id, + entityId: id, + groupId: 'host-group-1', + worktreeId: WT, + contentType: 'terminal', + label: parentTab, + customLabel: null, + color: null, + sortOrder, + createdAt: NOW + sortOrder, + isPreview: false, + isPinned: false + } + return { + id, + parentTab, + active, + storeTab: { + id, + ptyId: `remote:${ENV}@@${parentTab}-pty`, + worktreeId: WT, + title: parentTab, + customTitle: null, + color: null, + sortOrder, + createdAt: NOW + sortOrder + }, + unified + } +} + +function stateWithLocalTerminals( + locals: ReturnType[] +): WebSessionTabsSyncState { + const active = locals.find((l) => l.active) ?? locals[0] + return makeState({ + activeTabId: active?.id ?? null, + activeTabIdByWorktree: active ? { [WT]: active.id } : {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WT]: 'terminal' }, + tabsByWorktree: { [WT]: locals.map((l) => l.storeTab) }, + unifiedTabsByWorktree: { [WT]: locals.map((l) => l.unified) }, + tabBarOrderByWorktree: { [WT]: locals.map((l) => l.id) }, + groupsByWorktree: { + [WT]: [ + { + id: 'host-group-1', + worktreeId: WT, + activeTabId: active?.id ?? null, + tabOrder: locals.map((l) => l.id), + recentTabIds: locals.map((l) => l.id) + } + ] + } + }) +} + +function groupOrder(patch: Partial): string[] { + return patch.groupsByWorktree?.[WT]?.[0]?.tabOrder ?? [] +} +function groupActive(patch: Partial): string | null { + return patch.groupsByWorktree?.[WT]?.[0]?.activeTabId ?? null +} + +beforeEach(() => { + resetWebSessionTabsSnapshotFreshnessForTests() + resetWebSessionFocusIntentForTests() +}) + +// ────────────────────────────────────────────────────────────────────────── +// Plan §3 — Terminal tab creation ordering (new tab lands rightmost, like local) +// ────────────────────────────────────────────────────────────────────────── +describe('parity §3: remote terminal create appends rightmost (matches local)', () => { + it('a newly created remote terminal is ordered last, after existing tabs', () => { + const prior = stateWithLocalTerminals([ + localTerminal('host-tab-1', 0, true), + localTerminal('host-tab-2', 1, false) + ]) + recordWebSessionFocusIntent(WT, `host-tab-3::${LEAF_C}`) + const patch = applyWebSessionTabsSnapshot( + prior, + makeSnapshot([ + { parentTab: 'host-tab-1', leaf: LEAF_A, active: false }, + { parentTab: 'host-tab-2', leaf: LEAF_B, active: false }, + { parentTab: 'host-tab-3', leaf: LEAF_C, active: true } + ]), + ENV, + NOW + 10 + ) as Partial + + const order = groupOrder(patch) + // Local rule: new tab appends to the end. The new one must be last. + expect(order.at(-1)).toBe(toWebTerminalSurfaceTabId('host-tab-3')) + expect(order).toEqual([ + toWebTerminalSurfaceTabId('host-tab-1'), + toWebTerminalSurfaceTabId('host-tab-2'), + toWebTerminalSurfaceTabId('host-tab-3') + ]) + }) + + it('preserves existing relative order when adding (no reshuffle)', () => { + const prior = stateWithLocalTerminals([ + localTerminal('host-tab-1', 0, false), + localTerminal('host-tab-2', 1, true) + ]) + recordWebSessionFocusIntent(WT, `host-tab-3::${LEAF_C}`) + const patch = applyWebSessionTabsSnapshot( + prior, + makeSnapshot([ + { parentTab: 'host-tab-1', leaf: LEAF_A, active: false }, + { parentTab: 'host-tab-2', leaf: LEAF_B, active: false }, + { parentTab: 'host-tab-3', leaf: LEAF_C, active: true } + ]), + ENV, + NOW + 10 + ) as Partial + expect(groupOrder(patch)).toEqual([ + toWebTerminalSurfaceTabId('host-tab-1'), + toWebTerminalSurfaceTabId('host-tab-2'), + toWebTerminalSurfaceTabId('host-tab-3') + ]) + }) +}) + +// ────────────────────────────────────────────────────────────────────────── +// Plan §4 — Focus & activation (create focuses; status echo never steals focus) +// ────────────────────────────────────────────────────────────────────────── +describe('parity §4: remote create focuses new tab; echoes never steal focus', () => { + it('a client-initiated create focuses the new terminal (intent honored)', () => { + const prior = stateWithLocalTerminals([localTerminal('host-tab-1', 0, true)]) + recordWebSessionFocusIntent(WT, `host-tab-2::${LEAF_B}`) + const patch = applyWebSessionTabsSnapshot( + prior, + makeSnapshot([ + { parentTab: 'host-tab-1', leaf: LEAF_A, active: false }, + { parentTab: 'host-tab-2', leaf: LEAF_B, active: true } + ]), + ENV, + NOW + 10 + ) as Partial + expect(patch.activeTabIdByWorktree?.[WT]).toBe(toWebTerminalSurfaceTabId('host-tab-2')) + expect(groupActive(patch)).toBe(toWebTerminalSurfaceTabId('host-tab-2')) + }) + + it('an unsolicited status echo (no intent) does NOT steal focus from current', () => { + const prior = stateWithLocalTerminals([ + localTerminal('host-tab-1', 0, false), // agent tab + localTerminal('host-tab-2', 1, true) // user is here + ]) + // No focus intent recorded — this is an agent "thinking" echo marking tab-1 active. + const patch = applyWebSessionTabsSnapshot( + prior, + makeSnapshot([ + { parentTab: 'host-tab-1', leaf: LEAF_A, active: true }, + { parentTab: 'host-tab-2', leaf: LEAF_B, active: false } + ]), + ENV, + NOW + 10 + ) as Partial + // Focus stays on the user's current tab (tab-2), not the echoed tab-1. + expect(groupActive(patch)).toBe(toWebTerminalSurfaceTabId('host-tab-2')) + }) +}) + +// ────────────────────────────────────────────────────────────────────────── +// Plan §2 — Sidebar integrity: stale state ignored; newer snapshots applied +// ────────────────────────────────────────────────────────────────────────── +describe('parity §2: snapshot freshness (no split-brain from stale updates)', () => { + it('ignores a stale lower-version snapshot after a newer version applied', () => { + const prior = stateWithLocalTerminals([localTerminal('host-tab-1', 0, true)]) + const newer = makeSnapshot([{ parentTab: 'host-tab-1', leaf: LEAF_A, active: true }], { + snapshotVersion: 3, + publicationEpoch: 'epoch-x' + }) + const older = makeSnapshot([{ parentTab: 'host-tab-1', leaf: LEAF_A, active: true }], { + snapshotVersion: 2, + publicationEpoch: 'epoch-x' + }) + const first = applyFreshWebSessionTabsSnapshot(prior, newer, ENV, NOW + 10) + const afterNewer = { ...prior, ...(first as Partial) } + // Re-applying an older version of the same epoch must be a no-op (returns state). + const second = applyFreshWebSessionTabsSnapshot(afterNewer, older, ENV, NOW + 20) + expect(second).toBe(afterNewer) + }) +}) + +// ────────────────────────────────────────────────────────────────────────── +// Plan §11 — Browser tab create focuses the new browser (intent), like local. +// ────────────────────────────────────────────────────────────────────────── +describe('parity §11: remote browser create focuses the new browser tab', () => { + it('honors focus intent for a newly created browser session tab', () => { + const pageId = 'browser-page-1' + const prior = stateWithLocalTerminals([localTerminal('host-tab-1', 0, true)]) + recordWebSessionFocusIntent(WT, pageId) + const patch = applyWebSessionTabsSnapshot( + prior, + makeSnapshot([{ parentTab: 'host-tab-1', leaf: LEAF_A, active: false }], { + activeTabId: pageId, + activeTabType: 'browser', + tabGroups: [ + { + id: 'host-group-1', + activeTabId: pageId, + tabOrder: ['host-tab-1', pageId] + } + ], + tabs: [ + terminalSnapshotTab({ parentTab: 'host-tab-1', leaf: LEAF_A, active: false }), + { + type: 'browser', + id: pageId, + title: 'example.com', + browserWorkspaceId: pageId, + browserPageId: pageId, + url: 'https://example.com', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: true + } + ] + }), + ENV, + NOW + 10 + ) as Partial + // Visible type flips to browser and the active browser workspace is the new page. + expect(patch.activeTabTypeByWorktree?.[WT]).toBe('browser') + expect(patch.activeBrowserTabIdByWorktree?.[WT]).toBe(pageId) + }) + + it('a browser status echo without intent does not steal focus from a terminal', () => { + const pageId = 'browser-page-1' + const prior = makeState({ + activeTabId: toWebTerminalSurfaceTabId('host-tab-1'), + activeTabIdByWorktree: { [WT]: toWebTerminalSurfaceTabId('host-tab-1') }, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WT]: 'terminal' }, + tabsByWorktree: { [WT]: [localTerminal('host-tab-1', 0, true).storeTab] }, + unifiedTabsByWorktree: { [WT]: [localTerminal('host-tab-1', 0, true).unified] }, + tabBarOrderByWorktree: { [WT]: [toWebTerminalSurfaceTabId('host-tab-1')] }, + groupsByWorktree: { + [WT]: [ + { + id: 'host-group-1', + worktreeId: WT, + activeTabId: toWebTerminalSurfaceTabId('host-tab-1'), + tabOrder: [toWebTerminalSurfaceTabId('host-tab-1')], + recentTabIds: [toWebTerminalSurfaceTabId('host-tab-1')] + } + ] + }, + browserTabsByWorktree: { + [WT]: [ + { + id: pageId, + worktreeId: WT, + activePageId: pageId, + pageIds: [pageId], + url: 'https://example.com', + title: 'example.com', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: NOW + } + ] + } + }) + // No intent — server snapshot marks the (pre-existing) browser active. + const patch = applyWebSessionTabsSnapshot( + prior, + makeSnapshot([{ parentTab: 'host-tab-1', leaf: LEAF_A, active: false }], { + activeTabId: pageId, + activeTabType: 'browser', + tabGroups: [{ id: 'host-group-1', activeTabId: pageId, tabOrder: ['host-tab-1', pageId] }], + tabs: [ + terminalSnapshotTab({ parentTab: 'host-tab-1', leaf: LEAF_A, active: false }), + { + type: 'browser', + id: pageId, + title: 'example.com', + browserWorkspaceId: pageId, + browserPageId: pageId, + url: 'https://example.com', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: true + } + ] + }), + ENV, + NOW + 10 + ) as Partial + // No focus theft: the visible type must NOT flip to browser. The reconcile + // emits no change for an unchanged field, so it's either absent (unchanged) + // or still 'terminal' — never 'browser'. + const nextType = patch.activeTabTypeByWorktree?.[WT] ?? 'terminal' + expect(nextType).toBe('terminal') + }) +}) + +// ────────────────────────────────────────────────────────────────────────── +// Plan §1 — Lifecycle: an emptied host snapshot does not bootstrap phantom tabs +// when local terminals already exist (sleep/disconnect must not invent state). +// ────────────────────────────────────────────────────────────────────────── +describe('parity §1: empty host snapshot does not fabricate tabs', () => { + it('an empty snapshot with existing local terminals does not add a phantom tab', () => { + const prior = stateWithLocalTerminals([localTerminal('host-tab-1', 0, true)]) + const patch = applyWebSessionTabsSnapshot( + prior, + makeSnapshot([], { activeTabId: null, activeTabType: null }), + ENV, + NOW + 10 + ) as Partial + // No new fabricated terminal ids beyond what existed. + const order = groupOrder(patch) + for (const id of order) { + expect(id).toBe(toWebTerminalSurfaceTabId('host-tab-1')) + } + }) +}) diff --git a/src/renderer/src/runtime/remote-terminal-layout-resolution.test.ts b/src/renderer/src/runtime/remote-terminal-layout-resolution.test.ts new file mode 100644 index 00000000000..ce2510771c7 --- /dev/null +++ b/src/renderer/src/runtime/remote-terminal-layout-resolution.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalPaneLayoutNode } from '../../../shared/types' +import { layoutCoversLeaves, resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution' + +const verticalSplit: TerminalPaneLayoutNode = { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'a' }, + second: { type: 'leaf', leafId: 'b' } +} + +describe('layoutCoversLeaves', () => { + it('is true when the tree has exactly the leaves', () => { + expect(layoutCoversLeaves(verticalSplit, ['a', 'b'])).toBe(true) + }) + it('is false when a leaf is missing from the tree', () => { + expect(layoutCoversLeaves({ type: 'leaf', leafId: 'a' }, ['a', 'b'])).toBe(false) + }) + it('is false when the tree has an extra leaf', () => { + expect(layoutCoversLeaves(verticalSplit, ['a'])).toBe(false) + }) + it('is false for a null tree', () => { + expect(layoutCoversLeaves(null, ['a'])).toBe(false) + }) +}) + +describe('resolveTerminalLayoutRoot', () => { + it('uses the authoritative tree verbatim — direction is preserved', () => { + // Why: the "Split Right renders as down" bug was re-deriving direction + // instead of trusting the host tree. The authoritative tree must win. + const root = resolveTerminalLayoutRoot({ + authoritativeRoot: verticalSplit, + leafIds: ['a', 'b'] + }) + expect(root).toBe(verticalSplit) + expect(root?.type === 'split' && root.direction).toBe('vertical') + }) + + it('falls back to the prior client tree (keeping direction) when authoritative does not cover the leaves', () => { + // A transitional snapshot where the host tree is momentarily stale/partial + // must NOT collapse to a guessed direction — keep the known-good tree. + const root = resolveTerminalLayoutRoot({ + authoritativeRoot: { type: 'leaf', leafId: 'a' }, // stale single-leaf + existingRoot: verticalSplit, + leafIds: ['a', 'b'] + }) + expect(root).toBe(verticalSplit) + }) + + it('never invents a split direction: synthesis only fires (and is reported) when no tree covers the leaves', () => { + const onSynthesize = vi.fn() + resolveTerminalLayoutRoot({ + authoritativeRoot: undefined, + existingRoot: undefined, + leafIds: ['a', 'b'], + onSynthesize + }) + expect(onSynthesize).toHaveBeenCalledWith(2) + }) + + it('does not report synthesis for a single leaf (direction is irrelevant)', () => { + const onSynthesize = vi.fn() + const root = resolveTerminalLayoutRoot({ leafIds: ['a'], onSynthesize }) + expect(root).toEqual({ type: 'leaf', leafId: 'a' }) + expect(onSynthesize).not.toHaveBeenCalled() + }) + + it('returns null for no leaves', () => { + expect(resolveTerminalLayoutRoot({ leafIds: [] })).toBeNull() + }) + + it('prefers authoritative over an also-covering existing tree', () => { + const horizontalSplit: TerminalPaneLayoutNode = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'a' }, + second: { type: 'leaf', leafId: 'b' } + } + const root = resolveTerminalLayoutRoot({ + authoritativeRoot: verticalSplit, + existingRoot: horizontalSplit, + leafIds: ['a', 'b'] + }) + expect(root).toBe(verticalSplit) + }) +}) diff --git a/src/renderer/src/runtime/remote-terminal-layout-resolution.ts b/src/renderer/src/runtime/remote-terminal-layout-resolution.ts new file mode 100644 index 00000000000..7070f55b57c --- /dev/null +++ b/src/renderer/src/runtime/remote-terminal-layout-resolution.ts @@ -0,0 +1,98 @@ +import type { TerminalPaneLayoutNode } from '../../../shared/types' + +/** + * Single source of truth for turning a set of terminal leaves into a layout + * tree on the client. The host's published layout is authoritative; this module + * exists so every client ingestion path resolves the tree the same way instead + * of independently re-deriving it (which is how "Split Right" used to render as + * a down split — divergent fallbacks each guessed a direction). + * + * Invariant: NEVER invent a split direction. A split's direction is meaningful + * user/host state, so a guessed direction is wrong by construction. When no + * authoritative tree covers the leaves, we keep whatever covering tree we do + * have, and only as a true last resort synthesize a degenerate chain — logged + * so the gap is visible rather than silently masquerading as a real layout. + */ + +function collectLayoutLeafIds( + node: TerminalPaneLayoutNode | null | undefined, + leafIds = new Set() +): Set { + if (!node) { + return leafIds + } + if (node.type === 'leaf') { + leafIds.add(node.leafId) + return leafIds + } + collectLayoutLeafIds(node.first, leafIds) + collectLayoutLeafIds(node.second, leafIds) + return leafIds +} + +/** Whether `root` is a layout for exactly `leafIds` — every leaf present, no extras. */ +export function layoutCoversLeaves( + root: TerminalPaneLayoutNode | null | undefined, + leafIds: readonly string[] +): boolean { + if (!root) { + return false + } + const treeLeafIds = collectLayoutLeafIds(root) + const known = new Set(leafIds) + return ( + leafIds.every((leafId) => treeLeafIds.has(leafId)) && + [...treeLeafIds].every((leafId) => known.has(leafId)) + ) +} + +/** + * Last-resort tree when no authoritative or prior layout covers the leaves. + * A single leaf needs no direction; >1 leaf cannot be rendered as a split + * without inventing one, so this path is degenerate and should not fire for a + * real split — callers pass `onSynthesize` to surface when it does. + */ +function synthesizeDegenerateLayout( + leafIds: readonly string[], + onSynthesize?: (leafCount: number) => void +): TerminalPaneLayoutNode | null { + if (leafIds.length === 0) { + return null + } + if (leafIds.length === 1) { + return { type: 'leaf', leafId: leafIds[0]! } + } + onSynthesize?.(leafIds.length) + // No known direction: stack left-to-right as a flat chain. This is a visible + // fallback, not a guess we want to win — see invariant above. + return leafIds.slice(1).reduce( + (root, leafId) => ({ + type: 'split', + direction: 'horizontal', + first: root, + second: { type: 'leaf', leafId } + }), + { type: 'leaf', leafId: leafIds[0]! } + ) +} + +/** + * Resolve the layout tree for `leafIds`, preferring authoritative/known trees + * (which carry the real direction) over any synthesized fallback. + * + * Precedence: host-authoritative layout → prior client layout → degenerate. + */ +export function resolveTerminalLayoutRoot(args: { + authoritativeRoot?: TerminalPaneLayoutNode | null + existingRoot?: TerminalPaneLayoutNode | null + leafIds: readonly string[] + onSynthesize?: (leafCount: number) => void +}): TerminalPaneLayoutNode | null { + if (layoutCoversLeaves(args.authoritativeRoot, args.leafIds)) { + return args.authoritativeRoot ?? null + } + if (layoutCoversLeaves(args.existingRoot, args.leafIds)) { + return args.existingRoot ?? null + } + return synthesizeDegenerateLayout(args.leafIds, args.onSynthesize) +} diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index cba5040b706..17e4df7e8f4 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -30,7 +30,6 @@ import type { TabGroup, TabGroupLayoutNode, TerminalLayoutSnapshot, - TerminalPaneLayoutNode, TerminalTab } from '../../../shared/types' import { resolveTerminalTabTitle } from '../../../shared/tab-title-resolution' @@ -39,6 +38,7 @@ import { getGroupVisibleTabOrder, type VisibleTabRef } from '../components/tab-bar/group-tab-order' +import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution' import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' type RegisteredTerminalTab = { @@ -618,7 +618,14 @@ async function syncRuntimeGraph(): Promise { worktreeId, title, activeLeafId: layout?.activeLeafId ?? liveLeaves[0][0], - layout: layout?.root ?? fallbackLayoutForLeafIds(liveLeaves.map(([leafId]) => leafId)) + layout: resolveTerminalLayoutRoot({ + authoritativeRoot: layout?.root, + leafIds: liveLeaves.map(([leafId]) => leafId), + onSynthesize: (leafCount) => + console.warn( + `[sync-runtime-graph] synthesized layout for ${leafCount} unmounted leaves with no saved tree` + ) + }) }) liveLeaves.forEach(([leafId, ptyId], index) => { graph.leaves.push({ @@ -1187,22 +1194,6 @@ function resolveMobileTerminalTheme( return { mode: appearance.mode, theme: theme as RuntimeMobileTerminalTheme['theme'] } } -function fallbackLayoutForLeafIds(leafIds: readonly string[]): TerminalPaneLayoutNode | null { - const leaves = leafIds.filter(isTerminalLeafId) - if (leaves.length === 0) { - return null - } - return leaves.slice(1).reduce( - (root, leafId) => ({ - type: 'split', - direction: 'horizontal', - first: root, - second: { type: 'leaf', leafId } - }), - { type: 'leaf', leafId: leaves[0]! } - ) -} - function getRuntimeLeafIdsForTerminal(tabId: string, state: AppState): string[] { const registered = registeredTabs.get(tabId) const manager = registered?.getManager() @@ -1259,7 +1250,18 @@ function buildMobileTerminalSurfaceTabs( typeof HTMLElement !== 'undefined' && firstChild instanceof HTMLElement ? firstChild : null ) const parentLayout = normalizeTerminalLayoutSnapshot({ - root: liveLayoutRoot ?? sanitizedSavedLayout?.root ?? fallbackLayoutForLeafIds(leafIds), + // Why: the live DOM tree (when mounted) is authoritative; otherwise the + // saved tree. Both carry the real direction — only synthesize as a last + // resort, never re-guess. Shared with the client-ingest path. + root: resolveTerminalLayoutRoot({ + authoritativeRoot: liveLayoutRoot, + existingRoot: sanitizedSavedLayout?.root, + leafIds, + onSynthesize: (leafCount) => + console.warn( + `[sync-runtime-graph] synthesized parentLayout for ${leafCount} leaves with no live or saved tree` + ) + }), activeLeafId, expandedLeafId: sanitizedSavedLayout?.expandedLeafId ?? null, ...(Object.keys(savedPtyIdsByLeafId).length > 0 ? { ptyIdsByLeafId: savedPtyIdsByLeafId } : {}), diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 2524f8fd0fa..833cfdbce1f 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -10,6 +10,7 @@ import { createWebRuntimeSessionTerminal, isWebRuntimeSessionActive, moveWebRuntimeSessionTab, + setWebRuntimeTabProps, splitWebRuntimeTerminal } from './web-runtime-session' @@ -22,7 +23,8 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyFreshWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), - trackTerminalPaneSplit: vi.fn() + trackTerminalPaneSplit: vi.fn(), + getRuntimeEnvironmentIdForWorktree: vi.fn() })) vi.mock('../store', () => ({ @@ -41,6 +43,10 @@ vi.mock('@/lib/feature-education-telemetry', () => ({ trackTerminalPaneSplit: mocks.trackTerminalPaneSplit })) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: mocks.getRuntimeEnvironmentIdForWorktree +})) + const ENVIRONMENT_ID = 'web-env-1' const WORKTREE_ID = 'repo::/worktree' @@ -131,6 +137,9 @@ describe('createWebRuntimeSessionBrowserTab', () => { worktree: `id:${WORKTREE_ID}`, url: 'https://example.com/', profileId: undefined, + // Why: a user-initiated "New Browser Tab" focuses the new tab, which on a + // headless host marks it active in the session snapshot. + activate: true, waitForRegistration: false }, timeoutMs: 15_000 @@ -984,3 +993,51 @@ describe('closeWebRuntimeTerminal', () => { expect(isWebRuntimeSessionActive(null)).toBe(false) }) }) + +describe('setWebRuntimeTabProps', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('pushes pin to the host via session.tabs.setTabProps for a remote tab', async () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', false) + mocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue(ENVIRONMENT_ID) + mocks.getState.mockReturnValue({}) + const runtimeCall = vi.fn().mockResolvedValue({ id: 'p', ok: true, result: { updated: true } }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + expect( + setWebRuntimeTabProps({ + worktreeId: WORKTREE_ID, + tabId: 'web-terminal-host-tab-1', + isPinned: true + }) + ).toBe(true) + + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) + expect(runtimeCall).toHaveBeenCalledWith({ + selector: ENVIRONMENT_ID, + method: 'session.tabs.setTabProps', + params: { + worktree: `id:${WORKTREE_ID}`, + tabId: 'host-tab-1', + isPinned: true + }, + timeoutMs: 15_000 + }) + }) + + it('no-ops for a worktree with no runtime environment (local tab)', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', false) + mocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue(null) + mocks.getState.mockReturnValue({}) + const runtimeCall = vi.fn() + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + expect( + setWebRuntimeTabProps({ worktreeId: WORKTREE_ID, tabId: 'local-tab', color: '#fff' }) + ).toBe(false) + expect(runtimeCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 9fb542226e0..de901114666 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -11,12 +11,15 @@ import type { } from '../../../shared/runtime-types' import type { TerminalPaneSplitSource } from '../../../shared/feature-education-telemetry' import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery' -import type { TuiAgent } from '../../../shared/types' +import type { TerminalPaneLayoutNode, TuiAgent } from '../../../shared/types' import type { AppState } from '../store/types' +import { getRuntimeEnvironmentIdForWorktree } from '../lib/worktree-runtime-owner' import { useAppStore } from '../store' import { unwrapRuntimeRpcResult } from './runtime-rpc-client' import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' +import { recordWebSessionFocusIntent } from './web-session-focus-intent' +import { recordWebSessionCloseIntent } from './web-session-close-intent' import { isWebTerminalSurfaceTabId, toHostSessionTabId } from './web-terminal-surface-id' export { @@ -76,7 +79,14 @@ export async function createWebRuntimeSessionTerminal(args: { }, timeoutMs: 15_000 }) - unwrapRuntimeRpcResult(response as RuntimeRpcResponse) + const createdTerminal = unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) + if (args.activate !== false) { + // Why: record focus intent so the reconcile follows the snapshot's active + // tab to THIS new terminal, instead of sticky-keeping the prior tab. + recordWebSessionFocusIntent(args.worktreeId, createdTerminal.tab.id) + } await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId) return true } catch (error) { @@ -117,6 +127,13 @@ export async function createWebRuntimeSessionBrowserTab(args: { worktree: toRuntimeWorktreeSelector(args.worktreeId), url: args.url, profileId: args.profileId ?? undefined, + // Why: this is the user clicking "New Browser Tab", so focus it. On a + // headless host this marks the tab active in the session snapshot so the + // reconcile keeps focus on it instead of snapping back to a terminal. + activate: true, + // Why: place the new browser in the split group whose "+" was clicked, + // so the host snapshot is authoritative for its group (no left-snap). + ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), // Why: paired web clients need the local tab immediately. The remote // pane will stream once the host webview registers; waiting here makes // the workspace appear to close while the host finishes mounting. @@ -125,6 +142,10 @@ export async function createWebRuntimeSessionBrowserTab(args: { timeoutMs: 15_000 }) const created = unwrapRuntimeRpcResult(response as RuntimeRpcResponse) + // Why: record focus intent (browser session tab id === browserPageId on a + // headless host) so the reconcile follows the snapshot's active tab to this + // new browser tab rather than sticky-keeping the prior one. + recordWebSessionFocusIntent(args.worktreeId, created.browserPageId) stageWebRuntimeBrowserTab({ environmentId, worktreeId: args.worktreeId, @@ -417,6 +438,12 @@ async function callWebRuntimeSessionTabMethod( worktreeId: args.worktreeId, tabId: args.tabId }) ?? toHostSessionTabId(args.tabId) + if (method === 'session.tabs.close') { + // Why: the local mirror is pruned before this resolves, so suppress this + // host tab in the reconcile until the host snapshot confirms removal — + // otherwise an in-flight pre-close snapshot makes the tab flash back. + recordWebSessionCloseIntent(args.worktreeId, hostTabId, Date.now()) + } const response = await window.api.runtimeEnvironments.call({ selector: environmentId, method, @@ -595,3 +622,118 @@ export function closeWebRuntimeTerminal(ptyId: string | null | undefined): boole }) return true } + +// Why: pane geometry inside a tab (split ratios, expanded pane, pane titles) is +// host-authoritative for remote-server tabs, so a local-only divider drag / +// expand / pane-rename reverts on the next snapshot. Push the structure to the +// host so it persists. tabId is the local web tab id; we resolve the host id. +export async function updateWebRuntimePaneLayout(args: { + worktreeId: string + tabId: string + root: TerminalPaneLayoutNode | null + expandedLeafId: string | null + titlesByLeafId?: Record +}): Promise { + const environmentId = + getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), args.worktreeId) ?? null + if (!environmentId || !isWebRuntimeSessionActive(environmentId)) { + return false + } + const hostTabId = isWebTerminalSurfaceTabId(args.tabId) + ? toHostSessionTabId(args.tabId) + : args.tabId + try { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.updatePaneLayout', + params: { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + tabId: hostTabId, + root: args.root, + expandedLeafId: args.expandedLeafId, + ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) + }, + timeoutMs: 15_000 + }) + unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ updated: true }>) + return true + } catch (error) { + console.warn( + '[web-runtime-session] failed to update pane layout:', + error instanceof Error ? error.message : String(error) + ) + return false + } +} + +// Why: tab color/pin are host-authoritative for remote-server tabs; mirror the +// change to the host so it persists and survives the next snapshot. Pass only +// the fields that changed (undefined = leave as-is on the host). +export function setWebRuntimeTabProps(args: { + worktreeId: string + tabId: string + color?: string | null + isPinned?: boolean +}): boolean { + const environmentId = + getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), args.worktreeId) ?? null + if (!environmentId || !isWebRuntimeSessionActive(environmentId)) { + return false + } + const hostTabId = isWebTerminalSurfaceTabId(args.tabId) + ? toHostSessionTabId(args.tabId) + : args.tabId + void window.api.runtimeEnvironments + .call({ + selector: environmentId, + method: 'session.tabs.setTabProps', + params: { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + tabId: hostTabId, + ...(args.color !== undefined ? { color: args.color } : {}), + ...(args.isPinned !== undefined ? { isPinned: args.isPinned } : {}) + }, + timeoutMs: 15_000 + }) + .then((response) => { + unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ updated: true }>) + }) + .catch((error) => { + console.warn( + '[web-runtime-session] failed to set tab props:', + error instanceof Error ? error.message : String(error) + ) + }) + return true +} + +// Why: clearing scrollback locally (pane.terminal.clear()) is undone by the next +// host snapshot/re-subscribe, which replays the host buffer. Clear the host +// buffer too so the clear actually sticks on a remote-server pane. +export function clearWebRuntimeTerminalBuffer(ptyId: string | null | undefined): boolean { + if (!ptyId) { + return false + } + const remote = parseRemoteRuntimePtyId(ptyId) + const environmentId = remote?.environmentId?.trim() + if (!remote || !environmentId || !isWebRuntimeSessionActive(environmentId)) { + return false + } + void window.api.runtimeEnvironments + .call({ + selector: environmentId, + method: 'terminal.clearBuffer', + params: { terminal: remote.handle }, + timeoutMs: 15_000 + }) + .then((response) => { + unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ clear: unknown }>) + }) + .catch((error) => { + console.warn( + '[web-runtime-session] failed to clear terminal buffer:', + error instanceof Error ? error.message : String(error) + ) + }) + return true +} diff --git a/src/renderer/src/runtime/web-session-close-intent.test.ts b/src/renderer/src/runtime/web-session-close-intent.test.ts new file mode 100644 index 00000000000..185342b99da --- /dev/null +++ b/src/renderer/src/runtime/web-session-close-intent.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + isWebSessionCloseIntentPending, + reconcileWebSessionCloseIntents, + recordWebSessionCloseIntent, + resetWebSessionCloseIntentForTests +} from './web-session-close-intent' + +const WT = 'repo::/wt' + +afterEach(() => resetWebSessionCloseIntentForTests()) + +describe('web session close intent', () => { + it('marks a closing host tab pending until the host confirms removal', () => { + recordWebSessionCloseIntent(WT, 'host-tab-1', 1000) + expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000)).toBe(true) + + // A snapshot that still contains the tab keeps the intent (not confirmed). + reconcileWebSessionCloseIntents(WT, new Set(['host-tab-1', 'host-tab-2'])) + expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000)).toBe(true) + + // A snapshot WITHOUT the tab confirms removal and clears the intent. + reconcileWebSessionCloseIntents(WT, new Set(['host-tab-2'])) + expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000)).toBe(false) + }) + + it('expires a never-confirmed close so the tab is not hidden forever', () => { + recordWebSessionCloseIntent(WT, 'host-tab-1', 1000) + expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000)).toBe(true) + // Past the TTL with no confirming snapshot — stop suppressing. + expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000 + 11_000)).toBe(false) + }) + + it('scopes intents per worktree', () => { + recordWebSessionCloseIntent(WT, 'host-tab-1', 1000) + expect(isWebSessionCloseIntentPending('other::/wt', 'host-tab-1', 1000)).toBe(false) + }) + + it('ignores empty ids', () => { + recordWebSessionCloseIntent(WT, ' ', 1000) + expect(isWebSessionCloseIntentPending(WT, '', 1000)).toBe(false) + }) +}) diff --git a/src/renderer/src/runtime/web-session-close-intent.ts b/src/renderer/src/runtime/web-session-close-intent.ts new file mode 100644 index 00000000000..a8f6549c080 --- /dev/null +++ b/src/renderer/src/runtime/web-session-close-intent.ts @@ -0,0 +1,92 @@ +// Why: closing a remote tab prunes the local mirror immediately for +// responsiveness, then asks the host to close it. But an in-flight host snapshot +// (published before the host processed the close, or the close RPC's own +// pre-close subscribe replay) can arrive AFTER the local prune and still contain +// the tab — the reconcile then re-materializes the just-closed tab, which the +// host's real post-close snapshot removes again a beat later. That round trip is +// the close "flash and reappear". +// +// The client records its own close intent here (host tab id pending removal). +// The reconcile drops any host tab matching a pending intent until a snapshot +// confirms the removal (tab absent), then clears it — mirroring the focus-intent +// mechanism. A TTL guards against a never-confirmed close (e.g. failed RPC) +// permanently hiding a tab that legitimately still exists host-side. + +const CLOSE_INTENT_TTL_MS = 10_000 + +type CloseIntent = { recordedAt: number } + +// worktreeId -> (hostTabId -> intent) +const pendingCloseByWorktree = new Map>() + +export function recordWebSessionCloseIntent( + worktreeId: string, + hostTabId: string, + now: number +): void { + const trimmed = hostTabId.trim() + if (!worktreeId || !trimmed) { + return + } + let byTab = pendingCloseByWorktree.get(worktreeId) + if (!byTab) { + byTab = new Map() + pendingCloseByWorktree.set(worktreeId, byTab) + } + byTab.set(trimmed, { recordedAt: now }) +} + +/** + * Whether a host tab should be hidden because the client is closing it. Expired + * intents are dropped (the close never confirmed — let the tab reappear rather + * than hide it forever). + */ +export function isWebSessionCloseIntentPending( + worktreeId: string, + hostTabId: string, + now: number +): boolean { + const byTab = pendingCloseByWorktree.get(worktreeId) + const intent = byTab?.get(hostTabId) + if (!intent) { + return false + } + if (now - intent.recordedAt > CLOSE_INTENT_TTL_MS) { + byTab!.delete(hostTabId) + if (byTab!.size === 0) { + pendingCloseByWorktree.delete(worktreeId) + } + return false + } + return true +} + +/** + * Clear close intents the host snapshot has confirmed: any pending host tab id + * NOT in `presentHostTabIds` has been removed host-side, so the intent is done. + */ +export function reconcileWebSessionCloseIntents( + worktreeId: string, + presentHostTabIds: ReadonlySet +): void { + const byTab = pendingCloseByWorktree.get(worktreeId) + if (!byTab) { + return + } + const confirmed: string[] = [] + for (const hostTabId of byTab.keys()) { + if (!presentHostTabIds.has(hostTabId)) { + confirmed.push(hostTabId) + } + } + for (const hostTabId of confirmed) { + byTab.delete(hostTabId) + } + if (byTab.size === 0) { + pendingCloseByWorktree.delete(worktreeId) + } +} + +export function resetWebSessionCloseIntentForTests(): void { + pendingCloseByWorktree.clear() +} diff --git a/src/renderer/src/runtime/web-session-focus-intent.ts b/src/renderer/src/runtime/web-session-focus-intent.ts new file mode 100644 index 00000000000..a5c9997cb4f --- /dev/null +++ b/src/renderer/src/runtime/web-session-focus-intent.ts @@ -0,0 +1,32 @@ +// Why: a remote tab create/activate is the ONE case where the session snapshot's +// activeTabId reflects genuine user focus intent. Status-echo snapshots (e.g. an +// agent "thinking" during a run) also set activeTabId but must NOT steal focus +// (#5435). The snapshot can't distinguish these, so the client records its own +// activation intent here: the reconcile only follows the snapshot's active tab +// when it matches a pending intent the client itself initiated. +// +// Keyed by worktree id → the host session tab id the client expects to focus. +// The intent persists until a snapshot matches it (surviving racing/duplicate +// snapshots, unlike a transient per-snapshot flag). + +const pendingFocusByWorktree = new Map() + +export function recordWebSessionFocusIntent(worktreeId: string, hostTabId: string): void { + const trimmed = hostTabId.trim() + if (!worktreeId || !trimmed) { + return + } + pendingFocusByWorktree.set(worktreeId, trimmed) +} + +export function peekWebSessionFocusIntent(worktreeId: string): string | null { + return pendingFocusByWorktree.get(worktreeId) ?? null +} + +export function clearWebSessionFocusIntent(worktreeId: string): void { + pendingFocusByWorktree.delete(worktreeId) +} + +export function resetWebSessionFocusIntentForTests(): void { + pendingFocusByWorktree.clear() +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 287ea9eb51d..c6c334c5e25 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -4,6 +4,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { makePaneKey } from '../../../shared/stable-pane-id' import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { + recordWebSessionFocusIntent, + resetWebSessionFocusIntentForTests +} from './web-session-focus-intent' +import { + recordWebSessionCloseIntent, + resetWebSessionCloseIntentForTests +} from './web-session-close-intent' import type { BrowserPage, BrowserWorkspace, Tab, TerminalTab } from '../../../shared/types' import type { OpenFile } from '../store/slices/editor' import { @@ -84,6 +92,8 @@ function makeSnapshot( describe('applyWebSessionTabsSnapshot', () => { beforeEach(() => { resetWebSessionTabsSnapshotFreshnessForTests() + resetWebSessionFocusIntentForTests() + resetWebSessionCloseIntentForTests() }) it('ignores stale or duplicate same-epoch snapshots after a newer version was applied', () => { @@ -99,6 +109,66 @@ describe('applyWebSessionTabsSnapshot', () => { expect(applyFreshWebSessionTabsSnapshot(afterNewer, newer, ENV, NOW)).toBe(afterNewer) }) + it('applies a lower-version snapshot from a different epoch (host restart safety)', () => { + // Why: snapshotVersion resets when the host restarts, and a restart produces + // a new publicationEpoch. Since the client's freshness tracking survives a + // transparent transport reconnect, rejecting on version alone would + // permanently drop the post-restart snapshot. A different epoch must apply + // even at a lower version; only same-epoch non-newer frames are stale. + const before = makeSnapshot([], { + publicationEpoch: 'epoch-gen-1', + snapshotVersion: 10, + activeTabType: null + }) + const afterRestart = makeSnapshot([], { + publicationEpoch: 'epoch-gen-2', + snapshotVersion: 1, + activeTabType: null + }) + + expect(shouldApplyWebSessionTabsSnapshot(before, ENV)).toBe(true) + expect(shouldApplyWebSessionTabsSnapshot(afterRestart, ENV)).toBe(true) + // Same-epoch non-newer frames are still rejected as stale/duplicate. + const sameEpochOlder = makeSnapshot([], { + publicationEpoch: 'epoch-gen-2', + snapshotVersion: 1, + activeTabType: null + }) + expect(shouldApplyWebSessionTabsSnapshot(sameEpochOlder, ENV)).toBe(false) + }) + + it('suppresses a tab the client is closing until the host confirms removal (no close flash)', () => { + const surface = { + type: 'terminal' as const, + id: HOST_SURFACE_ID, + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + title: 'Terminal', + status: 'ready' as const, + terminal: 'term_host', + isActive: true + } + // Client closed host-tab-1; an in-flight pre-close snapshot still lists it. + recordWebSessionCloseIntent(WT, 'host-tab-1', NOW) + const stalePreClose = applyWebSessionTabsSnapshot(makeState(), makeSnapshot([surface]), ENV, NOW) + expect((stalePreClose.tabsByWorktree?.[WT] ?? []).map((tab) => tab.id)).not.toContain( + toWebTerminalSurfaceTabId('host-tab-1') + ) + + // The host's post-close snapshot omits the tab -> intent clears; a later + // snapshot that re-adds the SAME id (a genuinely new tab) is no longer hidden. + applyWebSessionTabsSnapshot(makeState(), makeSnapshot([]), ENV, NOW + 1) + const reopened = applyWebSessionTabsSnapshot( + makeState(), + makeSnapshot([surface], { snapshotVersion: 5 }), + ENV, + NOW + 2 + ) + expect((reopened.tabsByWorktree?.[WT] ?? []).map((tab) => tab.id)).toContain( + toWebTerminalSurfaceTabId('host-tab-1') + ) + }) + it('does not bootstrap a terminal from a stale empty active-worktree snapshot', () => { const ready = makeSnapshot([ { @@ -1552,6 +1622,105 @@ describe('applyWebSessionTabsSnapshot', () => { }) }) + it('focuses a brand-new remote terminal that the snapshot marks active', () => { + // Why: opening a new terminal must take focus. Distinct from the #5435 case + // above (existing tab echoed active = keep focus); here the active tab is new. + const existingTabId = toWebTerminalSurfaceTabId('host-tab-1') + const newTabId = toWebTerminalSurfaceTabId('host-tab-2') + // Simulate createWebRuntimeSessionTerminal recording focus intent for the new tab. + recordWebSessionFocusIntent(WT, `host-tab-2::${SECOND_LEAF_ID}`) + const existingUnifiedTab: Tab = { + id: existingTabId, + entityId: existingTabId, + groupId: 'host-group-1', + worktreeId: WT, + contentType: 'terminal', + label: 'shell', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW, + isPreview: false, + isPinned: false + } + + const patch = applyWebSessionTabsSnapshot( + makeState({ + activeTabId: existingTabId, + activeTabIdByWorktree: { [WT]: existingTabId }, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WT]: 'terminal' }, + tabsByWorktree: { + [WT]: [ + { + id: existingTabId, + ptyId: 'remote:web-env-1@@terminal-1', + worktreeId: WT, + title: 'shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + ] + }, + unifiedTabsByWorktree: { [WT]: [existingUnifiedTab] }, + tabBarOrderByWorktree: { [WT]: [existingTabId] }, + groupsByWorktree: { + [WT]: [ + { + id: 'host-group-1', + worktreeId: WT, + activeTabId: existingTabId, + tabOrder: [existingTabId], + recentTabIds: [existingTabId] + } + ] + } + }), + makeSnapshot( + [ + { + type: 'terminal', + id: `host-tab-1::${LEAF_ID}`, + title: 'shell', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: false, + status: 'ready', + terminal: 'terminal-1' + }, + { + type: 'terminal', + id: `host-tab-2::${SECOND_LEAF_ID}`, + title: 'new shell', + parentTabId: 'host-tab-2', + leafId: SECOND_LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-2' + } + ], + { + activeTabId: `host-tab-2::${SECOND_LEAF_ID}`, + activeTabType: 'terminal', + tabGroups: [ + { + id: 'host-group-1', + activeTabId: 'host-tab-2', + tabOrder: ['host-tab-1', 'host-tab-2'] + } + ] + } + ), + ENV, + NOW + 10 + ) as Partial + + expect(patch.activeTabIdByWorktree?.[WT]).toBe(newTabId) + expect(patch.groupsByWorktree?.[WT]?.[0]?.activeTabId).toBe(newTabId) + }) + it('does not let repeated remote split status snapshots steal local pane focus', () => { const mirroredTabId = toWebTerminalSurfaceTabId('host-tab-1') const currentLayout = { diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index ec7d94e9e56..c34ab7f5f7c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -39,7 +39,13 @@ import { toWebTerminalSurfaceTabId, WEB_TERMINAL_SURFACE_TAB_PREFIX } from './web-runtime-session' +import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' +import { clearWebSessionFocusIntent, peekWebSessionFocusIntent } from './web-session-focus-intent' +import { + isWebSessionCloseIntentPending, + reconcileWebSessionCloseIntents +} from './web-session-close-intent' import { beginWebRuntimeWakeTerminalRespawn, clearAllWebRuntimeWakeTerminalRespawn, @@ -167,6 +173,15 @@ export function shouldApplyWebSessionTabsSnapshot( } rememberHostTerminalTabCount(environmentId, snapshot) const current = latestSessionTabsSnapshotByWorktree.get(key) + // Why: snapshotVersion is monotonic only WITHIN one host generation; it resets + // when the host restarts, and each restart produces a different publicationEpoch. + // So only treat a frame as stale when it shares the current epoch and isn't + // newer — a different epoch is a new generation (or a restart) and must apply, + // even if its version is lower. (A cross-stream out-of-order frame with a + // different epoch may briefly apply, but the next snapshot's higher version + // self-heals it; rejecting on version alone would instead permanently drop a + // post-restart snapshot, since the client's tracking survives transparent + // transport reconnects.) if ( current && current.publicationEpoch === snapshot.publicationEpoch && @@ -358,37 +373,6 @@ function isMirroredTerminalSurfaceId(tabId: string): boolean { ) } -function fallbackLayoutForLeafIds(leafIds: readonly string[]): TerminalPaneLayoutNode | null { - if (leafIds.length === 0) { - return null - } - return leafIds.slice(1).reduce( - (root, leafId) => ({ - type: 'split', - direction: 'horizontal', - first: root, - second: { type: 'leaf', leafId } - }), - { type: 'leaf', leafId: leafIds[0]! } - ) -} - -function collectLayoutLeafIds( - node: TerminalPaneLayoutNode | null | undefined, - leafIds = new Set() -): Set { - if (!node) { - return leafIds - } - if (node.type === 'leaf') { - leafIds.add(node.leafId) - return leafIds - } - collectLayoutLeafIds(node.first, leafIds) - collectLayoutLeafIds(node.second, leafIds) - return leafIds -} - function chooseRemoteTerminalLayout( surfaces: readonly TerminalSurface[], ptyIdsByLeafId: Record, @@ -402,11 +386,6 @@ function chooseRemoteTerminalLayout( parentLayoutSource.title ]) : undefined - const parentLayoutLeafIds = collectLayoutLeafIds(parentLayout?.root) - const canReuseParentLayout = - parentLayout?.root && - leafIds.every((leafId) => parentLayoutLeafIds.has(leafId)) && - [...parentLayoutLeafIds].every((leafId) => knownLeafIds.has(leafId)) const activeLeafId = // Why: host title/status snapshots may still mark an agent pane active // after this client selected a different split pane. @@ -424,7 +403,18 @@ function chooseRemoteTerminalLayout( ? parentLayout.expandedLeafId : null return { - root: canReuseParentLayout ? parentLayout.root : fallbackLayoutForLeafIds(leafIds), + // Why: the host's parentLayout is authoritative (carries the real split + // direction); only if it doesn't cover the current leaves do we keep the + // prior client tree, then degenerate — never re-guess a direction. + root: resolveTerminalLayoutRoot({ + authoritativeRoot: parentLayout?.root, + existingRoot: existingLayout?.root, + leafIds, + onSynthesize: (leafCount) => + console.warn( + `[web-session-tabs-sync] synthesized layout for ${leafCount} leaves; no authoritative or prior tree covered them` + ) + }), activeLeafId, expandedLeafId, ptyIdsByLeafId, @@ -520,6 +510,16 @@ function buildMirroredTerminalTabs( activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent ?? existing?.launchAgent + // Why: tab color/pin echo back through host snapshots, so prefer the client's + // own record (kept authoritative in tabsByWorktree by the pin/color setters) + // and fall back to the host value only when this client has no prior tab — + // e.g. first reconcile or a change made on another client. Mirrors how + // customTitle always prefers the client value to avoid echo-window reverts. + const hostColorSurface = surfaces.find((surface) => surface.color != null) + const color = existing ? (existing.color ?? null) : (hostColorSurface?.color ?? null) + const isPinned = existing + ? existing.isPinned === true + : surfaces.some((surface) => surface.isPinned) return { tab: { id: localTabId, @@ -529,7 +529,8 @@ function buildMirroredTerminalTabs( defaultTitle: existing?.defaultTitle ?? title, ...(quickCommandLabel ? { quickCommandLabel } : {}), customTitle: existing?.customTitle ?? null, - color: existing?.color ?? null, + color, + isPinned, sortOrder: sortOffset + index, createdAt: existing?.createdAt ?? now + index, // Why: runtime snapshots can omit launchAgent after the process settles; @@ -668,7 +669,7 @@ function buildTerminalUnifiedTab(tab: TerminalTab, groupId: string): Tab { sortOrder: tab.sortOrder, createdAt: tab.createdAt, isPreview: false, - isPinned: false + isPinned: tab.isPinned === true } } @@ -1511,11 +1512,54 @@ function findCurrentVisibleUnifiedTabId(args: { export function applyWebSessionTabsSnapshot( state: WebSessionTabsSyncState, - snapshot: RuntimeMobileSessionTabsResult, + rawSnapshot: RuntimeMobileSessionTabsResult, environmentId: string, now = Date.now() ): WebSessionTabsSyncState | Partial { - const worktreeId = snapshot.worktree + const worktreeId = rawSnapshot.worktree + // Why: a remote close prunes the local mirror immediately, but an in-flight + // pre-close snapshot can still list the tab and flash it back. Drop any tab + // the client is closing until the host confirms removal; reconcile the intents + // against the full snapshot first so confirmed (absent) closes clear. + const snapshotHostTabId = (tab: RuntimeMobileSessionTabsResult['tabs'][number]): string => + tab.type === 'terminal' + ? tab.parentTabId + : tab.type === 'browser' + ? (tab.browserPageId ?? tab.id) + : tab.id + reconcileWebSessionCloseIntents( + worktreeId, + new Set(rawSnapshot.tabs.map((tab) => snapshotHostTabId(tab))) + ) + const snapshot: RuntimeMobileSessionTabsResult = rawSnapshot.tabs.some((tab) => + isWebSessionCloseIntentPending(worktreeId, snapshotHostTabId(tab), now) + ) + ? { + ...rawSnapshot, + tabs: rawSnapshot.tabs.filter( + (tab) => !isWebSessionCloseIntentPending(worktreeId, snapshotHostTabId(tab), now) + ) + } + : rawSnapshot + // Why: only follow the snapshot's active tab over the user's current focus when + // the client itself initiated this activation (a create/activate it recorded). + // An unsolicited server-active (e.g. an agent "thinking" echo) must not steal + // focus — that's the #5435 contract. Intent matches by the host active tab id + // (terminal session id, or browserPageId for browser tabs); consume it once. + const focusIntentHostTabId = peekWebSessionFocusIntent(worktreeId) + const honorSnapshotActiveFocus = + focusIntentHostTabId !== null && + snapshot.activeTabId !== null && + (snapshot.activeTabId === focusIntentHostTabId || + snapshot.tabs.some( + (tab) => + tab.id === snapshot.activeTabId && + tab.type === 'browser' && + tab.browserPageId === focusIntentHostTabId + )) + if (honorSnapshotActiveFocus) { + clearWebSessionFocusIntent(worktreeId) + } const currentTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] const existingTerminalById = new Map(currentTerminalTabs.map((tab) => [tab.id, tab])) const terminalSurfaceTabs = snapshot.tabs.filter(isTerminalSurfaceTab) @@ -1716,7 +1760,14 @@ export function applyWebSessionTabsSnapshot( (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabIdByWorktree[worktreeId]) ? state.activeTabIdByWorktree[worktreeId] : null + // Why: when the client initiated this activation (honorSnapshotActiveFocus), + // the snapshot's active terminal wins over the sticky current focus. + const intentTerminalId = + honorSnapshotActiveFocus && snapshot.activeTabType === 'terminal' + ? activeMirroredTerminalId + : null const nextActiveTerminalId = + intentTerminalId ?? currentActiveTerminalStillExists ?? (snapshot.activeTabType === 'terminal' ? (activeMirroredTerminalId ?? mirroredTerminalTabEntries[0]?.id) @@ -1727,7 +1778,12 @@ export function applyWebSessionTabsSnapshot( (nextBrowserTabs ?? []).some((tab) => tab.id === state.activeBrowserTabIdByWorktree[worktreeId]) ? state.activeBrowserTabIdByWorktree[worktreeId] : null + const intentBrowserWorkspaceId = + honorSnapshotActiveFocus && snapshot.activeTabType === 'browser' + ? activeMirroredBrowserWorkspaceId + : null const nextActiveBrowserWorkspaceId = + intentBrowserWorkspaceId ?? currentActiveBrowserStillExists ?? (snapshot.activeTabType === 'browser' ? (activeMirroredBrowserWorkspaceId ?? mirroredBrowserTabs[0]?.workspace.id) @@ -1752,7 +1808,19 @@ export function applyWebSessionTabsSnapshot( worktreeId, nextUnifiedTabs }) + // Why: a client-initiated activation also drives the visible unified tab, + // overriding the sticky current-visible tab. + const intentUnifiedTabId = honorSnapshotActiveFocus + ? snapshot.activeTabType === 'browser' + ? activeMirroredBrowserTabId + : snapshot.activeTabType === 'terminal' + ? intentTerminalId + : snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' + ? activeMirroredEditorTabId + : null + : null const nextActiveUnifiedTabId = + intentUnifiedTabId ?? currentVisibleUnifiedTabId ?? (snapshot.activeTabType === 'browser' ? (activeMirroredBrowserTabId ?? @@ -2097,8 +2165,11 @@ export function applyWebSessionTabsSnapshot( : ('terminal' as const) // Why: an empty/closed host snapshot has no active host tab, but the web // client must not keep pointing global shortcuts at a removed browser/editor. - const nextVisibleTabType = - currentVisibleTabTypeStillValid ?? snapshotVisibleTabType ?? fallbackVisibleTabType + // A client-initiated activation (honorSnapshotActiveFocus) makes the snapshot's + // type win, so a create can switch the visible pane (e.g. terminal -> browser). + const nextVisibleTabType = honorSnapshotActiveFocus + ? (snapshotVisibleTabType ?? currentVisibleTabTypeStillValid ?? fallbackVisibleTabType) + : (currentVisibleTabTypeStillValid ?? snapshotVisibleTabType ?? fallbackVisibleTabType) const currentActiveTerminalStillValid = state.activeTabId && (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabId) ? state.activeTabId diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index 314bdae32f6..99a42253b4e 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -674,8 +674,11 @@ describe('createBrowserSlice runtime guard', () => { expect(store.getState().recordFeatureInteraction).toHaveBeenCalledWith('browser-tab-created') }) - it('creates a local fallback tab when runtime browser creation fails', async () => { + it('does not create a local fallback tab when remote browser creation fails', async () => { const store = createTestStore() + // Why: a remote-owned workspace must stay remote-owned. If the remote host + // cannot create the page, we must NOT silently open a local desktop tab — + // that produces confusing split ownership (issue #5321 UX requirement). createWebRuntimeSessionBrowserTabMock.mockResolvedValueOnce(false) store.setState({ activeWorktreeId: 'wt-remote', @@ -690,19 +693,26 @@ describe('createBrowserSlice runtime guard', () => { url: 'about:blank', targetGroupId: 'group-1' }) - expect(store.getState().createUnifiedTab).toHaveBeenCalledWith( - 'wt-remote', - 'browser', - expect.objectContaining({ targetGroupId: 'group-1' }) + // No local tab created, no unified tab, no feature interaction recorded. + expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() + expect(store.getState().createUnifiedTab).not.toHaveBeenCalled() + expect(store.getState().recordFeatureInteraction).not.toHaveBeenCalledWith( + 'browser-tab-created' ) - const [tab] = store.getState().browserTabsByWorktree['wt-remote'] ?? [] - expect(tab).toBeDefined() - expect(store.getState().browserPagesByWorkspace[tab!.id]?.[0]).toMatchObject({ - browserRuntimeEnvironmentId: null, - url: 'about:blank', - title: 'New Tab' + }) + + it('does not create a local fallback tab when remote browser creation throws', async () => { + const store = createTestStore() + createWebRuntimeSessionBrowserTabMock.mockRejectedValueOnce(new Error('remote down')) + store.setState({ + activeWorktreeId: 'wt-remote', + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'] }) - expect(store.getState().recordFeatureInteraction).toHaveBeenCalledWith('browser-tab-created') + + await store.getState().openNewBrowserTabInActiveWorkspace('group-1') + + expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() + expect(store.getState().createUnifiedTab).not.toHaveBeenCalled() }) it('does not import local browser cookies while a runtime environment is active', async () => { diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 89344d6b569..160851e784e 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -615,18 +615,16 @@ export const createBrowserSlice: StateCreator = get().recordFeatureInteraction('browser-tab-created') return } - } catch { - // Fall through to the client-local fallback below. + } catch (error) { + // Why: a remote-owned workspace must NOT silently fall back to a local + // desktop browser tab — that creates confusing split ownership. Headless + // remotes that support browser panes advertise browser.headless.v1 and + // succeed above; if creation fails, surface it instead of going local. + console.warn( + '[browser] remote browser tab creation failed:', + error instanceof Error ? error.message : String(error) + ) } - // Why: headless remote runtimes cannot host browser panes yet. Keep the - // workspace remote-owned, but open this browser page on the desktop client. - get().createBrowserTab(worktreeId, defaultUrl, { - title: translate('auto.store.slices.browser.d175274b6d', 'New Browser Tab'), - focusAddressBar: true, - targetGroupId: groupId, - browserRuntimeEnvironmentId: null - }) - get().recordFeatureInteraction('browser-tab-created') return } get().createBrowserTab(worktreeId, defaultUrl, { diff --git a/src/renderer/src/store/slices/cmd-j-create-actions.test.ts b/src/renderer/src/store/slices/cmd-j-create-actions.test.ts index 385710b84a7..56174252881 100644 --- a/src/renderer/src/store/slices/cmd-j-create-actions.test.ts +++ b/src/renderer/src/store/slices/cmd-j-create-actions.test.ts @@ -41,7 +41,11 @@ describe('Cmd+J lifted creation actions', () => { delete pairedWebFlag.__ORCA_WEB_CLIENT__ }) - it('opens a local browser tab when paired-web browser creation fails', async () => { + it('does not fall back to a local browser tab when paired-web creation fails', async () => { + // Why: a remote-owned workspace must stay remote-owned. If the remote host + // cannot create the page, we must NOT silently open a local desktop tab — + // that produces confusing split ownership (issue #5321). Mirrors the + // terminal no-fallback contract below. createWebRuntimeSessionBrowserTabMock.mockResolvedValue(false) const store = createTestStore() seedActiveWorkspace(store) @@ -54,10 +58,10 @@ describe('Cmd+J lifted creation actions', () => { url: 'about:blank', targetGroupId: 'group-1' }) - expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toHaveLength(1) + expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toEqual([]) }) - it('creates browser tabs on the explicit owner runtime when another runtime is focused', async () => { + it('routes browser tab creation to the explicit owner runtime when another runtime is focused', async () => { createWebRuntimeSessionBrowserTabMock.mockResolvedValue(false) const store = createTestStore() seedActiveWorkspace(store) @@ -74,7 +78,8 @@ describe('Cmd+J lifted creation actions', () => { url: 'about:blank', targetGroupId: 'group-1' }) - expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toHaveLength(1) + // Remote-owned: no local fallback even when the remote create fails. + expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toEqual([]) }) it('creates a local browser tab for explicitly local workspaces while a runtime is focused', async () => { diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 90402f820d3..f7823486df9 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -1161,6 +1161,36 @@ describe('TabsSlice', () => { expect(store.getState().groupsByWorktree[WT][0].tabOrder).toEqual([t3.id, t2.id, t1.id]) }) + + it('syncs isPinned to the TerminalTab in tabsByWorktree (reconcile echo guard)', () => { + // Why: reconcile derives a remote tab's pin from tabsByWorktree[*].isPinned; + // if pin/unpin only touched unifiedTabsByWorktree, an unrelated host + // snapshot would recompute isPinned:false and un-pin during the echo window. + const tab = store.getState().createUnifiedTab(WT, 'terminal') + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WT]: [ + { + id: tab.id, + ptyId: null, + worktreeId: WT, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + })) + + store.getState().pinTab(tab.id) + expect(store.getState().tabsByWorktree[WT][0].isPinned).toBe(true) + + store.getState().unpinTab(tab.id) + expect(store.getState().tabsByWorktree[WT][0].isPinned).toBe(false) + }) }) // ─── closeOtherTabs ─────────────────────────────────────────────── diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 5e6f310adc7..0500aa1f9a5 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -9,6 +9,7 @@ import type { TabContentType, TabGroup, TabGroupLayoutNode, + TerminalTab, WorkspaceSessionState, WorkspaceVisibleTabType } from '../../../../shared/types' @@ -28,6 +29,7 @@ import { import { buildHydratedTabState, pruneTabGroupLayoutForGroups } from './tabs-hydration' import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers' import { createBrowserUuid } from '@/lib/browser-uuid' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' @@ -165,6 +167,48 @@ export type TabsSlice = { hydrateTabsSession: (session: WorkspaceSessionState) => void } +// Why: keep the TerminalTab (tabsByWorktree) pin in sync with the unified-tab +// pin so reconcile's `existing.isPinned` fallback stays authoritative locally. +// Returns an empty patch when the tab isn't a persisted TerminalTab. +function patchTerminalTabPinned( + tabsByWorktree: Record, + worktreeId: string, + tabId: string, + isPinned: boolean +): Partial> { + const tabs = tabsByWorktree[worktreeId] + if (!tabs?.some((tab) => tab.id === tabId)) { + return {} + } + return { + tabsByWorktree: { + ...tabsByWorktree, + [worktreeId]: tabs.map((tab) => (tab.id === tabId ? { ...tab, isPinned } : tab)) + } + } +} + +// Why: pin is host-authoritative for remote-server tabs, so mirror it to the +// host (like setTabColor) or it's lost on reconnect/restart/other clients. +// Dynamic import keeps this store slice off the runtime layer. +function mirrorTabPinnedToHost(state: AppState, tabId: string, isPinned: boolean): void { + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + // Why: only terminal tab pins are persisted host-side today (browser/editor + // tracked in #5729), so skip the RPC for other types instead of a no-op round + // trip. + if ( + !found || + found.tab.contentType !== 'terminal' || + !getRuntimeEnvironmentIdForWorktree(state, found.worktreeId) + ) { + return + } + const worktreeId = found.worktreeId + void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => + setWebRuntimeTabProps({ worktreeId, tabId, isPinned }) + ) +} + function buildSplitNode( existingGroupId: string, newGroupId: string, @@ -996,12 +1040,17 @@ export const createTabsSlice: StateCreator = (set, ...state.unifiedTabsByWorktree, [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) }, + // Why: reconcile derives a tab's pin from the TerminalTab (tabsByWorktree), + // so mirror the pin there too — otherwise an unrelated host snapshot + // recomputes isPinned:false and visually un-pins during the echo window. + ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, true), groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: updateGroup(groups, { ...group, tabOrder }) } } }) + mirrorTabPinnedToHost(get(), tabId, true) if (exists) { get().recordFeatureInteraction?.('terminal-tabs') } @@ -1031,12 +1080,14 @@ export const createTabsSlice: StateCreator = (set, ...state.unifiedTabsByWorktree, [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) }, + ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, false), groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: updateGroup(groups, { ...group, tabOrder }) } } }) + mirrorTabPinnedToHost(get(), tabId, false) if (exists) { get().recordFeatureInteraction?.('terminal-tabs') } diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 4f527e93279..4d06149df3b 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1516,6 +1516,17 @@ export const createTerminalSlice: StateCreator .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) if (item) { get().setUnifiedTabColor(item.id, color) + // Why: tab color is host-authoritative for remote-server tabs; mirror it + // so it persists instead of reverting on the next snapshot. + const state = get() + const owningWorktreeId = Object.keys(state.unifiedTabsByWorktree).find((wId) => + (state.unifiedTabsByWorktree[wId] ?? []).some((entry) => entry.id === item.id) + ) + if (owningWorktreeId && getRuntimeEnvironmentIdForWorktree(state, owningWorktreeId)) { + void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => + setWebRuntimeTabProps({ worktreeId: owningWorktreeId, tabId: item.id, color }) + ) + } } }, diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 49c057b8ccd..0eb5b269c80 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -25,6 +25,10 @@ export const PROJECT_HOST_SETUP_RUNTIME_CAPABILITY = 'project-host-setup.v1' as export const TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY = 'task-source-context.v1' as const export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v1' as const export const REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY = 'remote-runtime.shared-control.v1' as const +// Why: signals a host owns browser pages with no renderer (headless serve via the +// offscreen backend). Advertised only when that backend is actually available, so +// clients never fall back to a local desktop browser tab for a remote-owned page. +export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const export const RUNTIME_CAPABILITIES = [ 'runtime.status.compat.v1', diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 736027e1933..7a8575b5242 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -127,6 +127,9 @@ export type RuntimeMobileSessionTerminalTab = { agentStatus?: AgentStatusEntry | null launchAgent?: TuiAgent parentLayout?: TerminalLayoutSnapshot + /** Tab-level color/pin (per parentTabId), host-persisted for remote servers. */ + color?: string | null + isPinned?: boolean isActive: boolean } @@ -174,6 +177,8 @@ export type RuntimeMobileSessionBrowserTab = { loading: boolean canGoBack: boolean canGoForward: boolean + color?: string | null + isPinned?: boolean isActive: boolean } diff --git a/src/shared/types.ts b/src/shared/types.ts index 9c72b816e17..a207ba1e6d9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -805,6 +805,8 @@ export type TerminalTab = { quickCommandLabel?: string | null customTitle: string | null color: string | null + /** Pinned tabs survive "close others"; host-persisted for remote servers. */ + isPinned?: boolean sortOrder: number createdAt: number /** Bumped on shutdown so TerminalPane remounts with a fresh PTY. */ diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index a8556f180b5..1f7898bef36 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -74,6 +74,7 @@ const terminalTabSchema = z.object({ quickCommandLabel: z.string().nullable().optional(), customTitle: z.string().nullable(), color: z.string().nullable(), + isPinned: z.boolean().optional(), sortOrder: z.number(), createdAt: z.number(), generation: z.number().optional(), From 76317d6484352168d410f441552a3ae1de6da7a5 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Thu, 18 Jun 2026 23:00:31 -0700 Subject: [PATCH 014/163] fix: dismiss stuck browser address bar autocomplete on webview click (#5769) * fix: dismiss stuck browser address bar autocomplete on webview click Electron webview guests never dispatch outside pointer events to the renderer, so the address bar history dropdown could stay open after clicking the page. Close it on guest focus, window blur, and Escape. Co-authored-by: Cursor * test: cover browser address bar dismiss paths --------- Co-authored-by: Cursor Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../browser-pane/BrowserAddressBar.test.tsx | 107 ++++++++++++++++++ .../browser-pane/BrowserAddressBar.tsx | 71 +++++++++++- .../components/browser-pane/BrowserPane.tsx | 8 ++ 3 files changed, 181 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/browser-pane/BrowserAddressBar.test.tsx b/src/renderer/src/components/browser-pane/BrowserAddressBar.test.tsx index 0c636ee0c0c..0cf202ff2d8 100644 --- a/src/renderer/src/components/browser-pane/BrowserAddressBar.test.tsx +++ b/src/renderer/src/components/browser-pane/BrowserAddressBar.test.tsx @@ -141,4 +141,111 @@ describe('BrowserAddressBar autocomplete preview', () => { expect(onNavigate).not.toHaveBeenCalled() expect(onSubmit).not.toHaveBeenCalled() }) + + it('dismisses suggestions when the window blurs', async () => { + const onNavigate = vi.fn() + const onSubmit = vi.fn() + + await act(async () => { + root.render( + + ) + }) + + const input = container.querySelector('input[data-orca-browser-address-bar]') + expect(input).not.toBeNull() + + await act(async () => { + input?.focus() + }) + await act(async () => { + input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + }) + + expect(container.querySelector('[data-current-address-value="true"]')?.textContent).toBe( + 'http://localhost:3000/review-one' + ) + + await act(async () => { + window.dispatchEvent(new Event('blur')) + }) + + expect(container.querySelector('[data-current-address-value="true"]')?.textContent).toBe( + 'local' + ) + expect(onNavigate).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('dismisses suggestions when Escape is pressed outside React input handling', async () => { + const onNavigate = vi.fn() + const onSubmit = vi.fn() + + await act(async () => { + root.render( + + ) + }) + + const input = container.querySelector('input[data-orca-browser-address-bar]') + expect(input).not.toBeNull() + + await act(async () => { + input?.focus() + }) + await act(async () => { + input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + }) + + expect(container.querySelector('[data-current-address-value="true"]')?.textContent).toBe( + 'http://localhost:3000/review-one' + ) + + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + + expect(container.querySelector('[data-current-address-value="true"]')?.textContent).toBe( + 'local' + ) + expect(onNavigate).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('dismisses suggestions when focus moves into an Electron webview guest', async () => { + const onNavigate = vi.fn() + const onSubmit = vi.fn() + const webview = document.createElement('webview') + document.body.appendChild(webview) + + await act(async () => { + root.render( + + ) + }) + + const input = container.querySelector('input[data-orca-browser-address-bar]') + expect(input).not.toBeNull() + + await act(async () => { + input?.focus() + }) + await act(async () => { + input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + }) + + expect(container.querySelector('[data-current-address-value="true"]')?.textContent).toBe( + 'http://localhost:3000/review-one' + ) + + await act(async () => { + webview.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + }) + + expect(container.querySelector('[data-current-address-value="true"]')?.textContent).toBe( + 'local' + ) + expect(onNavigate).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx b/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx index 732300acb4a..973f2089740 100644 --- a/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx +++ b/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx @@ -14,6 +14,7 @@ type BrowserAddressBarProps = { onSubmit: () => void onNavigate: (url: string) => void inputRef: React.RefObject + dismissSuggestionsRef?: React.MutableRefObject<(() => void) | null> } export default function BrowserAddressBar({ @@ -21,7 +22,8 @@ export default function BrowserAddressBar({ onChange, onSubmit, onNavigate, - inputRef + inputRef, + dismissSuggestionsRef }: BrowserAddressBarProps): React.ReactElement { const [open, setOpen] = useState(false) const [selectedValueOverride, setSelectedValueOverride] = useState(null) @@ -131,11 +133,19 @@ export default function BrowserAddressBar({ onChange(typed) }, [onChange]) - const cancelSuggestionPreview = useCallback((): void => { + const dismissSuggestions = useCallback((): void => { + if (blurCloseTimerRef.current !== null) { + window.clearTimeout(blurCloseTimerRef.current) + blurCloseTimerRef.current = null + } restoreTypedQuery() setOpen(false) }, [restoreTypedQuery]) + const cancelSuggestionPreview = useCallback((): void => { + dismissSuggestions() + }, [dismissSuggestions]) + const selectedValue = selectedValueOverride && suggestions.some((suggestion) => suggestion.url === selectedValueOverride) @@ -272,13 +282,64 @@ export default function BrowserAddressBar({ if (inputRef.current && document.activeElement === inputRef.current) { return } - restoreTypedQuery() - setOpen(false) + dismissSuggestions() } - }, [open, suggestions.length, inputRef, restoreTypedQuery]) + }, [dismissSuggestions, open, suggestions.length, inputRef]) + + // Why: Electron guests run in a separate process, so clicking the + // page never dispatches pointerdown on the renderer document and Radix cannot + // detect an outside dismiss. Window blur and focus moves into the guest (the + // host tag) close the dropdown the same way BrowserImportHintButton + // does for its popover. + useEffect(() => { + if (!open) { + return + } + + const handleWindowBlur = (): void => { + dismissSuggestions() + } + + const handleFocusIn = (event: FocusEvent): void => { + const target = event.target + if (!(target instanceof HTMLElement) || target.tagName !== 'WEBVIEW') { + return + } + dismissSuggestions() + } + + const handleEscape = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') { + return + } + dismissSuggestions() + event.preventDefault() + event.stopImmediatePropagation() + } + + window.addEventListener('blur', handleWindowBlur) + document.addEventListener('focusin', handleFocusIn, true) + window.addEventListener('keydown', handleEscape, true) + return () => { + window.removeEventListener('blur', handleWindowBlur) + document.removeEventListener('focusin', handleFocusIn, true) + window.removeEventListener('keydown', handleEscape, true) + } + }, [dismissSuggestions, inputRef, open]) + + useEffect(() => { + if (!dismissSuggestionsRef) { + return + } + dismissSuggestionsRef.current = dismissSuggestions + return () => { + dismissSuggestionsRef.current = null + } + }, [dismissSuggestions, dismissSuggestionsRef]) return ( { // Why: Radix fires onOpenChange(false) when it detects an outside diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index e1b2919533c..554b4fbf769 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -2621,6 +2621,7 @@ function BrowserPagePane({ clearTimeout(browserZoomFeedbackTimerRef.current) }, []) const addressBarInputRef = useRef(null) + const dismissAddressBarSuggestionsRef = useRef<(() => void) | null>(null) const webviewRef = useRef(null) const browserTabIdRef = useRef(browserTab.id) browserTabIdRef.current = browserTab.id @@ -3510,6 +3511,10 @@ function BrowserPagePane({ webviewRef.current = webview + const dismissAddressBarSuggestions = (): void => { + dismissAddressBarSuggestionsRef.current?.() + } + const handleDomReady = (): void => { const webContentsId = webview.getWebContentsId() let queuedAnnotationViewportBridgeSync = false @@ -3744,6 +3749,7 @@ function BrowserPagePane({ } webview.addEventListener('dom-ready', handleDomReady) + webview.addEventListener('focus', dismissAddressBarSuggestions) webview.addEventListener('did-start-loading', handleDidStartLoading) webview.addEventListener('did-stop-loading', handleDidStopLoading) // Why: separate handler registered only on 'did-navigate' (full page loads), @@ -3776,6 +3782,7 @@ function BrowserPagePane({ return () => { webview.removeEventListener('dom-ready', handleDomReady) + webview.removeEventListener('focus', dismissAddressBarSuggestions) webview.removeEventListener('did-start-loading', handleDidStartLoading) webview.removeEventListener('did-stop-loading', handleDidStopLoading) webview.removeEventListener('did-navigate', handleDidNavigate) @@ -4712,6 +4719,7 @@ function BrowserPagePane({ onSubmit={submitAddressBar} onNavigate={navigateToUrl} inputRef={addressBarInputRef} + dismissSuggestionsRef={dismissAddressBarSuggestionsRef} /> From 1d5efeb9760d5c1e3118b2c2bb54d50f9397c936 Mon Sep 17 00:00:00 2001 From: Wolfie Date: Thu, 18 Jun 2026 23:10:21 -0700 Subject: [PATCH 015/163] fix: support worktree name selectors (#5696) Co-authored-by: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> --- skills/orca-cli/SKILL.md | 2 +- src/cli/help.ts | 4 ++-- src/cli/selectors.ts | 2 +- src/main/runtime/orca-runtime.test.ts | 20 ++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 4 ++++ 5 files changed, 28 insertions(+), 4 deletions(-) diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index 96187c9bd84..82dabe9ded8 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -66,7 +66,7 @@ orca worktree rm --worktree id: --force --json Selectors: -- `id:`, `path:`, `branch:`, `issue:` +- `id:`, `name:`, `path:`, `branch:`, `issue:` - `active` / `current` for the enclosing Orca-managed worktree from the shell cwd - For `worktree create --parent-worktree` only, folder/worktree parent context keys are also valid: `folder:`, `worktree:`, `id:folder:`, `id:worktree:` diff --git a/src/cli/help.ts b/src/cli/help.ts index 8e3826f9322..140f1bb8e97 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -228,7 +228,7 @@ Common Commands: Selectors: --repo Registered repo selector such as id:, name:, or path: - --worktree Worktree selector such as id:, branch:, issue:, path:, or active/current + --worktree Worktree selector such as id:, name:, branch:, issue:, path:, or active/current --terminal Runtime-issued terminal handle returned by \`orca terminal list --json\` --parent-worktree Parent worktree selector such as id:, branch:, issue:, path:, or active/current --no-parent Force no parent lineage for unrelated worktree creation/update @@ -505,7 +505,7 @@ export function formatFlagHelp(flag: string): string { 'to-x': '--to-x Destination window-local x coordinate', 'to-y': '--to-y Destination window-local y coordinate', worktree: - '--worktree Worktree selector such as id:, branch:, issue:, path:, or active/current', + '--worktree Worktree selector such as id:, name:, branch:, issue:, path:, or active/current', workspace: '--workspace Existing worktree selector for automation runs', 'workspace-status': '--workspace-status Board status id (defaults: todo, in-progress, in-review, completed)', diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index e92c6f02dda..b36efc8fd9c 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -39,7 +39,7 @@ function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient) // server, so cwd-derived worktree selectors are only valid locally. throw new RuntimeClientError( 'invalid_argument', - `${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:, branch:, issue:, or path:.` + `${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:, name:, branch:, issue:, or path:.` ) } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 9cf2c6c995e..52754b660a0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1673,6 +1673,26 @@ describe('OrcaRuntimeService', () => { }) }) + it('resolves name selectors against worktree display names', async () => { + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: TEST_WORKTREE_PATH, + head: 'abc', + branch: 'refs/heads/wolfiesch/orca-skill-smoke', + isBare: false, + isMainWorktree: false + } + ]) + + const runtime = new OrcaRuntimeService(store) + + const worktree = await runtime.showManagedWorktree('name:foo') + expect(worktree).toMatchObject({ + displayName: 'foo', + path: TEST_WORKTREE_PATH + }) + }) + it('routes SSH-backed forward-slash UNC file and git paths without collapsing the root', async () => { vi.mocked(listWorktrees).mockClear() vi.mocked(listWorktrees).mockRejectedValue(new Error('local git should not run for SSH repos')) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 051eb52b86a..1c68dff4b8b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -14935,6 +14935,10 @@ export class OrcaRuntimeService { candidates = worktrees.filter((worktree) => branchSelectorMatches(worktree.branch, branchSelector) ) + } else if (selector.startsWith('name:')) { + // Keep display-name matching exact so selector behavior stays deterministic + // and duplicate names use the same ambiguity path as other selectors. + candidates = worktrees.filter((worktree) => worktree.displayName === selector.slice(5)) } else if (selector.startsWith('issue:')) { candidates = worktrees.filter( (worktree) => From 7e6cc9ab495232b274b788579e398b69d14523be Mon Sep 17 00:00:00 2001 From: gobeyondpty <152504625+gobeyondpty@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:13:30 -0500 Subject: [PATCH 016/163] fix(window): remove double title bar on Linux (#5726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(window): remove double title bar on Linux On Linux the BrowserWindow titleBarStyle fell through to undefined, so the native WM title bar stayed and stacked on top of Orca's own renderer-drawn titlebar — two title bars. macOS (hiddenInset) and Windows (hidden) already suppress the native one. Drop the native frame on Linux (frame: false) and extend the renderer's Windows-only custom-titlebar branches (logo/menu anchor, app-name hiding, window-controls spacer + CSS vars, and the WindowControls min/max/close cluster) to Linux via a shared `hasCustomTitleBar` flag, so Linux gets one title bar with working window controls — matching the Windows custom-titlebar path. Fixes #5724 Co-Authored-By: Claude Opus 4.8 (1M context) * review: harden Linux custom titlebar gating Co-authored-by: Orca --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/main/window/createMainWindow.test.ts | 60 +++++++++++++++++++ src/main/window/createMainWindow.ts | 16 +++-- src/preload/index.ts | 12 ++-- src/renderer/src/App.tsx | 50 ++++++++++------ src/renderer/src/assets/main.css | 38 ++++++------ .../src/components/right-sidebar/index.tsx | 37 ++++++++---- ...ebar-titlebar-drag-regions.render.test.tsx | 41 ++++++++++++- .../components/tab-group/TabGroupPanel.tsx | 10 ++-- .../src/lib/desktop-window-chrome.test.ts | 18 ++++++ src/renderer/src/lib/desktop-window-chrome.ts | 15 +++++ 10 files changed, 229 insertions(+), 68 deletions(-) create mode 100644 src/renderer/src/lib/desktop-window-chrome.test.ts create mode 100644 src/renderer/src/lib/desktop-window-chrome.ts diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index be0d796c621..85a0e805c80 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -58,6 +58,16 @@ vi.mock('../browser/browser-manager', () => ({ import { createMainWindow, loadMainWindow } from './createMainWindow' import { ipcMain } from 'electron' +function withPlatform(platform: NodeJS.Platform, run: () => T): T { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return run() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + describe('createMainWindow', () => { beforeEach(() => { browserWindowMock.mockReset() @@ -169,7 +179,10 @@ describe('createMainWindow', () => { titleBarStyle: 'hidden' }) } else { + // Linux: native frame is dropped so the renderer titlebar isn't stacked + // under the WM title bar (double title bar). titleBarStyle stays unset. expect(browserWindowOptions.titleBarStyle).toBeUndefined() + expect(browserWindowOptions.frame).toBe(false) } expect(windowHandlers.windowOpen({ url: 'https://example.com' })).toEqual({ action: 'deny' }) @@ -226,6 +239,53 @@ describe('createMainWindow', () => { expect(attachGuestPoliciesMock).toHaveBeenCalledWith(guest) }) + it('sets platform-specific titlebar and frame options for every desktop platform', () => { + for (const [platform, expected] of [ + ['darwin', { titleBarStyle: 'hiddenInset', frame: undefined }], + ['win32', { titleBarStyle: 'hidden', frame: undefined }], + ['linux', { titleBarStyle: undefined, frame: false }] + ] satisfies [ + NodeJS.Platform, + { titleBarStyle: string | undefined; frame: boolean | undefined } + ][]) { + browserWindowMock.mockReset() + const webContents = { + on: vi.fn(), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + setWindowButtonPosition: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + withPlatform(platform, () => createMainWindow(null)) + + const browserWindowOptions = browserWindowMock.mock.calls[0]?.[0] + expect(browserWindowOptions.titleBarStyle).toBe(expected.titleBarStyle) + expect(browserWindowOptions.frame).toBe(expected.frame) + } + }) + it('supports all minus key variants for terminal zoom out', () => { const windowHandlers: Record void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index d0cc4332fcb..4cf418f14f3 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -268,6 +268,12 @@ export function createMainWindow( : process.platform === 'win32' ? 'hidden' : undefined, + // Why: Linux ignores titleBarStyle: 'hidden', so without this the native + // WM title bar stays and stacks on top of our renderer titlebar (double + // title bar). frame: false drops the native frame; the renderer draws its + // own titlebar + window controls (see WindowControls in App.tsx), matching + // the Windows custom-titlebar path. + ...(process.platform === 'linux' ? { frame: false } : {}), // Why: initial position for 1x zoom; syncTrafficLightPosition() adjusts // dynamically when the user changes UI zoom. ...(process.platform === 'darwin' @@ -1039,8 +1045,8 @@ export function createMainWindow( } ipcMain.on(trafficLightChannel, onSyncTrafficLights) - // Why: renderer-drawn window controls on Windows send these to replicate the - // native title bar buttons that 'hidden' titleBarStyle removes. + // Why: renderer-drawn window controls on Windows/Linux desktop send these to + // replicate the native title bar buttons hidden by custom chrome. const minimizeChannel = 'window:minimize' const onMinimize = (): void => { if (!mainWindow.isDestroyed()) { @@ -1079,9 +1085,9 @@ export function createMainWindow( } mainWindow.webContents.send('window:close-requested', { isQuitting: false }) } - // Why: the ··· button in the renderer-drawn title bar on Windows pops up - // the application menu at the cursor position, replicating the Alt-key - // reveal that autoHideMenuBar normally provides. + // Why: the ··· button in the renderer-drawn title bar on Windows/Linux + // desktop pops up the application menu at the cursor position, replicating + // the Alt-key reveal that autoHideMenuBar normally provides. const popupMenuChannel = 'menu:popup' const onPopupMenu = (): void => { Menu.getApplicationMenu()?.popup({ window: mainWindow }) diff --git a/src/preload/index.ts b/src/preload/index.ts index c8df889935f..cf336af415a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3244,19 +3244,19 @@ const api = { ipcRenderer.on('window:fullscreen-changed', listener) return () => ipcRenderer.removeListener('window:fullscreen-changed', listener) }, - /** Windows only: minimize the window via the renderer-drawn title bar. */ + /** Desktop custom titlebar only: minimize via renderer-drawn window controls. */ minimize: (): void => { ipcRenderer.send('window:minimize') }, - /** Windows only: toggle maximize/restore via the renderer-drawn title bar. */ + /** Desktop custom titlebar only: toggle maximize/restore via renderer-drawn controls. */ maximize: (): void => { ipcRenderer.send('window:maximize') }, - /** Windows only: read the current maximize state on mount, since + /** Desktop custom titlebar only: read the current maximize state on mount, since * window:maximize-changed only fires on transitions and a window that * starts maximized would otherwise show the wrong icon. */ isMaximized: (): Promise => ipcRenderer.invoke('window:isMaximized'), - /** Windows only: subscribe to maximize state changes so the renderer-drawn + /** Desktop custom titlebar only: subscribe to maximize state changes so the renderer-drawn * maximize button can show the correct restore/maximize icon. */ onMaximizeChanged: (callback: (isMaximized: boolean) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, isMaximized: boolean) => @@ -3264,14 +3264,14 @@ const api = { ipcRenderer.on('window:maximize-changed', listener) return () => ipcRenderer.removeListener('window:maximize-changed', listener) }, - /** Windows only: request a close from the renderer-drawn close button. + /** Desktop custom titlebar only: request a close from the renderer-drawn close button. * Routes through main so the BrowserWindow 'close' event fires and the * terminal-running confirmation guard in the renderer stays active. * window.close() is unreliable in sandboxed renderers. */ requestClose: (): void => { ipcRenderer.send('window:request-close') }, - /** Windows only: pop up the application menu at the cursor position. + /** Desktop custom titlebar only: pop up the application menu at the cursor position. * Replicates the Alt-key reveal that autoHideMenuBar normally provides, * triggered by the ··· button in the renderer-drawn title bar. */ popupMenu: (): void => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 789c177457c..138405745fa 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -24,6 +24,10 @@ import { SYNC_FIT_PANES_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/const import { syncZoomCSSVar } from '@/lib/ui-zoom' import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance' import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility' +import { + isPairedWebClientWindow, + shouldRenderDesktopWindowChrome +} from '@/lib/desktop-window-chrome' import { resolveLeftTitlebarChromeLayout } from '@/lib/titlebar-left-chrome' import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface' import { buildAppFontFamily } from '@/lib/app-font-family' @@ -154,6 +158,14 @@ import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialo const isMac = navigator.userAgent.includes('Mac') const isWindows = !isMac && navigator.userAgent.includes('Windows') const shortcutPlatform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' +// Why: Windows and Linux both run with the native title bar removed (Windows +// via titleBarStyle: 'hidden', Linux via frame: false), so the renderer draws +// its own logo/menu anchor and min/max/close controls on both. Paired web +// clients run in a browser tab, so they must not render desktop window chrome. +const hasCustomTitleBar = shouldRenderDesktopWindowChrome({ + platform: shortcutPlatform, + isWebClient: isPairedWebClientWindow() +}) function getKeybindingContext(target: EventTarget | null): KeybindingContext { return target instanceof HTMLElement && target.classList.contains('xterm-helper-textarea') @@ -177,9 +189,10 @@ type ShortcutDispatchInput = { preventDefault: () => void } -// Why: 'hidden' titleBarStyle on Windows removes the native OS title bar, -// so we render our own minimize/maximize/close buttons. These SVG icons match -// the Fluent/Win11 style: thin 10×10 paths on a 40×30 hit area. +// Why: Windows ('hidden' titleBarStyle) and Linux (frame: false) both remove +// the native OS title bar, so we render our own minimize/maximize/close +// buttons. These SVG icons match the Fluent/Win11 style: thin 10×10 paths on a +// 40×30 hit area. function WindowControls(): React.JSX.Element { const [maximized, setMaximized] = useState(false) useEffect(() => { @@ -1779,9 +1792,9 @@ function App(): React.JSX.Element {
{isMac && !isFullScreen ? (
- ) : isWindows ? ( - /* Why: on Windows the native title bar is hidden, so we render the - Orca logo as a non-interactive identity anchor and a ··· button + ) : hasCustomTitleBar ? ( + /* Why: on Windows/Linux the native title bar is removed, so we render + the Orca logo as a non-interactive identity anchor and a ··· button that pops up the application menu (the same menu revealed by Alt on the default autoHideMenuBar). */ <> @@ -1804,7 +1817,7 @@ function App(): React.JSX.Element { ) : (
)} - {showSidebar && !isWindows && ( + {showSidebar && !hasCustomTitleBar && ( <> {settings?.showTitlebarAppName !== false && ( @@ -1947,8 +1960,8 @@ function App(): React.JSX.Element { visible at a time. */} {!rightSidebarOpen && rightSidebarToggle} {/* Why: reserve space so content is not obscured by the - fixed-position window-controls overlay on Windows. */} - {isWindows &&
} + fixed-position window-controls overlay on Windows/Linux. */} + {hasCustomTitleBar &&
} ) @@ -1960,12 +1973,13 @@ function App(): React.JSX.Element { { '--collapsed-sidebar-header-width': `${collapsedSidebarHeaderWidth}px`, // Why: consumed by anything that needs to avoid the fixed-position - // window-controls overlay on Windows (floating sidebar toggle, right - // sidebar header, etc.) without hardcoding 138px in multiple places. - '--window-controls-width': isWindows ? '138px' : '0px', + // window-controls overlay on Windows/Linux (floating sidebar toggle, + // right sidebar header, etc.) without hardcoding 138px in multiple + // places. + '--window-controls-width': hasCustomTitleBar ? '138px' : '0px', // Why: consumed by the side-position activity bar to push icons below - // the fixed-position window-controls overlay on Windows. - '--window-controls-height': isWindows ? '36px' : '0px' + // the fixed-position window-controls overlay on Windows/Linux. + '--window-controls-height': hasCustomTitleBar ? '36px' : '0px' } as React.CSSProperties } > @@ -2114,9 +2128,9 @@ function App(): React.JSX.Element { { // Why: right: var(--window-controls-width) is the single // mechanism that keeps the toggle clear of the - // fixed-position window-controls overlay on Windows (138px) - // and sits at the right edge on non-Windows (0px). No - // internal spacer needed — adding one would push the button + // fixed-position window-controls overlay on custom desktop + // chrome (138px) and sits at the right edge otherwise (0px). + // No internal spacer needed — adding one would push the button // a further 138px to the left and cover the pane-actions // Ellipsis button with an un-clickable div. right: 'var(--window-controls-width)', @@ -2561,7 +2575,7 @@ function App(): React.JSX.Element { in DOM order. Electron's hit-test for drag regions is DOM-order-based and ignores z-index — placing WindowControls earlier caused the drag region to win, making the buttons unclickable. */} - {isWindows && } + {hasCustomTitleBar && }
) } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 882eeb90206..c5450cdd714 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -597,10 +597,10 @@ flex-shrink: 0; } -/* Why: small identity anchor on Windows where the native window chrome is - hidden. Sized to sit comfortably in the 36px titlebar with a little - horizontal breathing room. The SVG fill is white; light mode inverts it to - black so it reads on the light titlebar background. */ +/* Why: small identity anchor on desktop custom titlebars where native window + chrome is hidden. Sized to sit comfortably in the 36px titlebar with a + little horizontal breathing room. The SVG fill is white; light mode inverts + it to black so it reads on the light titlebar background. */ .titlebar-logo { height: 16px; flex-shrink: 0; @@ -614,10 +614,9 @@ filter: invert(1); } -/* Why: Windows-only custom title bar buttons that replace the native OS chrome - removed by titleBarStyle:'hidden'. Anchored to the top-right corner of the - viewport so they overlay any layout mode (full titlebar, workspace split, etc.) - and stay accessible at all times. */ +/* Why: desktop custom title bar buttons replace hidden native OS chrome. + Anchored to the top-right corner of the viewport so they overlay any layout + mode (full titlebar, workspace split, etc.) and stay accessible at all times. */ .window-controls { position: fixed; top: 0; @@ -656,7 +655,7 @@ /* Why: flex spacer that reserves the width occupied by the three window-control buttons (3 × 46px) so that the preceding titlebar content is not obscured by - the fixed-position overlay on Windows. */ + the fixed-position overlay when custom desktop chrome is active. */ .window-controls-titlebar-spacer { flex-shrink: 0; width: var(--window-controls-width, 0px); @@ -671,29 +670,28 @@ -webkit-app-region: no-drag; } -/* Why: the right sidebar header extends to the window's right edge. On Windows - the fixed-position window-controls overlay sits on top of that edge, so we - add matching padding-right so the header's close button (pr-1) isn't hidden - behind the overlay. --window-controls-width is 0px on non-Windows. */ +/* Why: the right sidebar header extends to the window's right edge. When custom + desktop chrome is active, the fixed-position window-controls overlay sits on + top of that edge, so we add matching padding-right so the header's close + button (pr-1) isn't hidden behind the overlay. */ .right-sidebar-header-inset { padding-right: var(--window-controls-width, 0px); } /* Why: in side-bar activity-bar mode the icon strip sits flush against the right window edge. The fixed-position window-controls overlay (height 36px, - width 138px) covers the top-right corner, so the topmost sidebar icons are - unreachable without this offset. --window-controls-height is 0px on - non-Windows so this rule is a no-op there. */ + width 138px) covers the top-right corner when custom desktop chrome is + active, so the topmost sidebar icons are unreachable without this offset. */ .side-activity-bar-windows-inset { padding-top: var(--window-controls-height, 0px); } /* Why: in side activity-bar mode the panel header does NOT extend to the window edge — the 40px icon strip sits to its right. But the window-controls overlay - is 138px wide, so it still overlaps the panel by (138 - 40) = 98px. Apply - exactly that remainder as padding-right so the close button clears the - minimize button. max(0px, ...) keeps this a no-op on non-Windows where the - var is 0px and the subtraction would otherwise go negative. */ + is 138px wide when custom desktop chrome is active, so it still overlaps the + panel by (138 - 40) = 98px. Apply exactly that remainder as padding-right so + the close button clears the minimize button. max(0px, ...) keeps this a no-op + when the var is 0px and the subtraction would otherwise go negative. */ .right-sidebar-header-side-inset { padding-right: max(0px, calc(var(--window-controls-width, 0px) - 40px)); } diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index e68faa6b55e..cc0eb6a36b8 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -43,11 +43,19 @@ import { useMeasuredWidth } from './right-sidebar-measured-width' import { normalizeRightSidebarRoute } from '@/store/right-sidebar-route' import { AgentSessionHistoryIcon } from './agent-session-history-icon' import { resolveRightSidebarEffectiveTab } from './right-sidebar-effective-tab' +import { + isPairedWebClientWindow, + shouldRenderDesktopWindowChrome +} from '@/lib/desktop-window-chrome' +import { getRendererAppPlatform } from '@/lib/renderer-app-platform' const ACTIVITY_BAR_SIDE_WIDTH = 40 -const isWindows = typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows') function RightSidebarInner(): React.JSX.Element { + const hasDesktopWindowChrome = shouldRenderDesktopWindowChrome({ + platform: getRendererAppPlatform(), + isWebClient: isPairedWebClientWindow() + }) const rightSidebarShortcut = useShortcutLabel('sidebar.right.toggle') const explorerShortcut = useShortcutLabel('sidebar.explorer.toggle') const sourceControlShortcut = useShortcutLabel('sidebar.sourceControl.toggle') @@ -289,7 +297,7 @@ function RightSidebarInner(): React.JSX.Element { /* ── Top activity bar: horizontal icon row ── */
- {!isWindows && ( + {!hasDesktopWindowChrome && (
{/* Why: the top strip shares a narrow titlebar with the close - button and Windows controls. Overflow goes behind More - instead of creating a horizontally scrollable toolbar. */} + button and desktop window controls. Overflow goes + behind More instead of creating a horizontally + scrollable toolbar. */}
{topActivityLayout.visibleItems.map((item) => ( )} - {isWindows && ( + {hasDesktopWindowChrome && (
)}
- {isWindows && ( + {hasDesktopWindowChrome && (
- {/* Why: Windows has fixed native-style controls in the titlebar - area; keep sidebar navigation in the sidebar body so the - titlebar stays visually native instead of crowded. */} + {/* Why: custom desktop chrome has fixed native-style controls + in the titlebar area; keep sidebar navigation in the + sidebar body so the titlebar stays visually native + instead of crowded. */}
{topActivityLayout.visibleItems.map((item) => ( {visibleItems.find((item) => item.id === effectiveTab)?.title ?? ''} diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx index e255c8aab52..6869daaa89c 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx @@ -6,7 +6,10 @@ import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import RightSidebar from './index' import { TopActivityOverflowMenu } from './activity-bar-buttons' -import { RIGHT_SIDEBAR_HEADER_NO_DRAG_CLASS_NAME } from './right-sidebar-titlebar-drag-regions' +import { + RIGHT_SIDEBAR_HEADER_NO_DRAG_CLASS_NAME, + RIGHT_SIDEBAR_WINDOWS_TOP_ACTIVITY_STRIP_CLASS_NAME +} from './right-sidebar-titlebar-drag-regions' import type { ActiveRightSidebarTab } from '@/store/slices/editor' const mockAppState = vi.hoisted(() => ({ @@ -194,9 +197,22 @@ function expectNoDrag(tag: string): void { expect(tag).toContain(RIGHT_SIDEBAR_HEADER_NO_DRAG_CLASS_NAME) } +function setRendererPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + platform: { + get: () => ({ platform, osRelease: '' }) + } + } + }) +} + describe('rendered right sidebar titlebar drag regions', () => { beforeEach(() => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = false + setRendererPlatform('darwin') mockAppState.rightSidebarOpen = true mockAppState.rightSidebarTab = 'explorer' mockAppState.setRightSidebarTab = vi.fn((tab: ActiveRightSidebarTab) => { @@ -235,6 +251,29 @@ describe('rendered right sidebar titlebar drag regions', () => { expect(markup).toContain(RIGHT_SIDEBAR_HEADER_NO_DRAG_CLASS_NAME) }) + it('uses the custom desktop chrome top strip on Linux desktop', () => { + setRendererPlatform('linux') + + const markup = renderToStaticMarkup() + const activityStrip = openingTag(markup, 'right-sidebar-activity-strip') + + expect(activityStrip).toContain('h-10') + expect(activityStrip).toContain('border-b') + expect(markup).toContain(RIGHT_SIDEBAR_WINDOWS_TOP_ACTIVITY_STRIP_CLASS_NAME) + }) + + it('keeps paired Linux web clients on the browser-style top strip', () => { + setRendererPlatform('linux') + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + + const markup = renderToStaticMarkup() + const activityStrip = openingTag(markup, 'right-sidebar-activity-strip') + + expect(activityStrip).toContain('pl-2') + expect(activityStrip).not.toContain('h-10') + expect(markup).not.toContain(RIGHT_SIDEBAR_WINDOWS_TOP_ACTIVITY_STRIP_CLASS_NAME) + }) + it('keeps the overflow trigger no-drag when it renders', () => { const markup = renderToStaticMarkup( { + it('renders custom chrome for frameless desktop Linux and Windows windows', () => { + expect(shouldRenderDesktopWindowChrome({ platform: 'linux', isWebClient: false })).toBe(true) + expect(shouldRenderDesktopWindowChrome({ platform: 'win32', isWebClient: false })).toBe(true) + }) + + it('keeps macOS on native traffic lights', () => { + expect(shouldRenderDesktopWindowChrome({ platform: 'darwin', isWebClient: false })).toBe(false) + }) + + it('does not render desktop-only window controls in the paired web client', () => { + expect(shouldRenderDesktopWindowChrome({ platform: 'linux', isWebClient: true })).toBe(false) + expect(shouldRenderDesktopWindowChrome({ platform: 'win32', isWebClient: true })).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/desktop-window-chrome.ts b/src/renderer/src/lib/desktop-window-chrome.ts new file mode 100644 index 00000000000..849b4bf8420 --- /dev/null +++ b/src/renderer/src/lib/desktop-window-chrome.ts @@ -0,0 +1,15 @@ +export type DesktopWindowChromeInput = { + platform: NodeJS.Platform + isWebClient: boolean +} + +export function isPairedWebClientWindow(): boolean { + return (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true +} + +export function shouldRenderDesktopWindowChrome({ + platform, + isWebClient +}: DesktopWindowChromeInput): boolean { + return !isWebClient && (platform === 'win32' || platform === 'linux') +} From 692d8d012451302c5941ec0918dce28f611dd545 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:18:33 -0700 Subject: [PATCH 017/163] Use platform Orca CLI command for Agent Teams (#5773) Co-authored-by: Orca --- .../runtime/claude-agent-teams-shim-env.ts | 19 +++++-------------- src/renderer/src/lib/agent-catalog.tsx | 17 ++++++++++++++++- src/shared/orca-cli-command-name.ts | 9 +++++++++ src/shared/tui-agent-config.ts | 14 ++++++++++++++ src/shared/tui-agent-startup.test.ts | 12 ++++++++++++ src/shared/tui-agent-startup.ts | 8 ++++++-- 6 files changed, 62 insertions(+), 17 deletions(-) create mode 100644 src/shared/orca-cli-command-name.ts diff --git a/src/main/runtime/claude-agent-teams-shim-env.ts b/src/main/runtime/claude-agent-teams-shim-env.ts index 2b5f472bd47..81a8746fa22 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.ts @@ -8,6 +8,7 @@ import { isDirectClaudeCommand, type ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' +import { getOrcaCliCommandNameForPlatform } from '../../shared/orca-cli-command-name' export type ClaudeAgentTeamsLaunchPlan = { command: string @@ -62,8 +63,8 @@ export function resolveClaudeAgentTeamsShimBin( } return ( findExecutableOnPath(process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev', env.PATH) ?? - findExecutableOnPath(platformCliCommandName(), env.PATH) ?? - platformCliCommandName() + findExecutableOnPath(getOrcaCliCommandNameForPlatform(process.platform), env.PATH) ?? + getOrcaCliCommandNameForPlatform(process.platform) ) } @@ -87,16 +88,6 @@ function bundledLauncherPath(): string | null { return null } -function platformCliCommandName(): string { - if (process.platform === 'linux') { - return 'orca-ide' - } - if (process.platform === 'win32') { - return 'orca.cmd' - } - return 'orca' -} - function findExecutableOnPath(command: string, pathValue: string | undefined): string | null { for (const directory of pathValue?.split(delimiter) ?? []) { if (!directory) { @@ -126,7 +117,7 @@ function unixShimScript(): string { return [ '#!/usr/bin/env sh', 'set -eu', - 'exec "${ORCA_AGENT_TEAMS_SHIM_BIN:-orca}" agent-teams-tmux "$@"', + `exec "\${ORCA_AGENT_TEAMS_SHIM_BIN:-${getOrcaCliCommandNameForPlatform(process.platform)}}" agent-teams-tmux "$@"`, '' ].join('\n') } @@ -136,7 +127,7 @@ function windowsShimScript(): string { '@echo off', 'setlocal', 'if "%ORCA_AGENT_TEAMS_SHIM_BIN%"=="" (', - ' set "ORCA_AGENT_TEAMS_SHIM_BIN=orca"', + ` set "ORCA_AGENT_TEAMS_SHIM_BIN=${getOrcaCliCommandNameForPlatform(process.platform)}"`, ')', '"%ORCA_AGENT_TEAMS_SHIM_BIN%" agent-teams-tmux %*', '' diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 2088b834b04..5b4d0ca2587 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -2,6 +2,7 @@ import type React from 'react' import { ClaudeIcon, DroidIcon, OpenAIIcon } from '@/components/status-bar/icons' import openClaudeLogoUrl from '../../../../resources/openclaude-logo.png?url' import type { TuiAgent } from '../../../shared/types' +import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { AgentLetterIcon, AiderIcon, @@ -26,6 +27,20 @@ export type AgentCatalogEntry = { homepageUrl: string } +function getCatalogPlatform(): NodeJS.Platform { + const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent + if (userAgent.includes('Windows')) { + return 'win32' + } + if (userAgent.includes('Mac')) { + return 'darwin' + } + if (userAgent) { + return 'linux' + } + return typeof process === 'undefined' ? 'linux' : process.platform +} + export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => [ { id: 'claude', @@ -36,7 +51,7 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => { id: 'claude-agent-teams', label: translate('auto.lib.agent.catalog.bf53f09bf8', 'Claude Agent Teams'), - cmd: 'orca claude-teams', + cmd: getTuiAgentLaunchCommand(TUI_AGENT_CONFIG['claude-agent-teams'], getCatalogPlatform()), homepageUrl: 'https://code.claude.com/docs/agent-teams' }, { diff --git a/src/shared/orca-cli-command-name.ts b/src/shared/orca-cli-command-name.ts new file mode 100644 index 00000000000..8d226e4ff3a --- /dev/null +++ b/src/shared/orca-cli-command-name.ts @@ -0,0 +1,9 @@ +export function getOrcaCliCommandNameForPlatform(platform: NodeJS.Platform): string { + if (platform === 'linux') { + return 'orca-ide' + } + if (platform === 'win32') { + return 'orca.cmd' + } + return 'orca' +} diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index a500f540b64..8f1488a0ec9 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -1,4 +1,5 @@ import type { TuiAgent } from './types' +import { getOrcaCliCommandNameForPlatform } from './orca-cli-command-name' export type AgentPromptInjectionMode = | 'argv' @@ -14,6 +15,8 @@ export type TuiAgentConfig = { /** Additional executable names that identify the same agent on PATH. */ detectCmdAliases?: readonly string[] launchCmd: string + /** Platform-specific launch command when the public binary name differs. */ + launchCmdByPlatform?: Partial> expectedProcess: string promptInjectionMode: AgentPromptInjectionMode /** Why: flag that launches the TUI with the given text already in the @@ -70,6 +73,10 @@ export const TUI_AGENT_CONFIG: Record = { detectCmd: 'orca', detectCmdAliases: ['orca-dev', 'orca-ide'], launchCmd: 'orca claude-teams', + launchCmdByPlatform: { + linux: `${getOrcaCliCommandNameForPlatform('linux')} claude-teams`, + win32: `${getOrcaCliCommandNameForPlatform('win32')} claude-teams` + }, expectedProcess: 'claude', promptInjectionMode: 'stdin-after-start' }, @@ -330,3 +337,10 @@ export function isTuiAgent(value: unknown): value is TuiAgent { export function getTuiAgentDetectCommands(config: TuiAgentConfig): string[] { return [config.detectCmd, ...(config.detectCmdAliases ?? [])] } + +export function getTuiAgentLaunchCommand( + config: TuiAgentConfig, + platform: NodeJS.Platform +): string { + return config.launchCmdByPlatform?.[platform] ?? config.launchCmd +} diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 7ef17bfcfaf..78868880368 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -89,6 +89,18 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).not.toContain('--settings') }) + it('uses the Linux Orca CLI command for Claude Agent Teams launches', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude-agent-teams', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true + }) + + expect(plan?.launchCommand).toBe('orca-ide claude-teams') + }) + it('launches OpenClaude as a distinct argv agent', () => { const plan = buildAgentStartupPlan({ agent: 'openclaude', diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 026134e7e06..482a1939398 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -5,7 +5,7 @@ import { type ResumableTuiAgent } from './agent-session-resume' import { tokenizeCustomCommandTemplate } from './commit-message-prompt' -import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' import type { StartupCommandDelivery } from './codex-startup-delivery' import type { TuiAgent } from './types' @@ -88,11 +88,12 @@ export function planAgentCliArgsSuffix( function resolveBaseCommand(args: { agent: TuiAgent cmdOverrides: Partial> + platform: NodeJS.Platform shell: AgentStartupShell agentArgs?: string | null }): { ok: true; command: string } | { ok: false; error: string } { const override = args.cmdOverrides[args.agent] - const command = override || TUI_AGENT_CONFIG[args.agent].launchCmd + const command = override || getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[args.agent], args.platform) const suffix = planAgentCliArgsSuffix(args.agentArgs, args.shell) if (!suffix.ok) { return suffix @@ -119,6 +120,7 @@ export function buildAgentStartupPlan(args: { const baseCommand = resolveBaseCommand({ agent, cmdOverrides, + platform, shell, agentArgs: args.agentArgs }) @@ -209,6 +211,7 @@ export function buildAgentResumeStartupPlan(args: { const baseCommand = resolveBaseCommand({ agent: args.agent, cmdOverrides: args.cmdOverrides, + platform: args.platform, shell, agentArgs: args.agentArgs }) @@ -272,6 +275,7 @@ export function buildAgentDraftLaunchPlan(args: { const baseCommand = resolveBaseCommand({ agent, cmdOverrides, + platform, shell, agentArgs: args.agentArgs }) From 238476f4412bf676f30bc9fbfbde9fea32d32c1e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:19:41 -0700 Subject: [PATCH 018/163] feat(sidebar): preview workspace board on card drag start with trigger cue (#5777) --- src/renderer/src/assets/main.css | 23 +++++++++++++ .../src/components/sidebar/SidebarToolbar.tsx | 9 ++++- .../src/components/sidebar/WorktreeList.tsx | 21 ++++-------- src/renderer/src/components/sidebar/index.tsx | 1 + ...orkspace-board-drag-preview-intent.test.ts | 34 ------------------- .../workspace-board-drag-preview-intent.ts | 13 ------- 6 files changed, 38 insertions(+), 63 deletions(-) delete mode 100644 src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.test.ts delete mode 100644 src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.ts diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index c5450cdd714..c1bc72aaab3 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1426,6 +1426,29 @@ opacity: 0.42; } +/* Why: while a card drag previews the board, pulse the toolbar trigger with the + same ring the drawer uses so it reads as another way to open the board. */ +@keyframes workspace-board-trigger-preview-pulse { + 0% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--sidebar-ring) 55%, transparent); + } + + 70%, + 100% { + box-shadow: 0 0 0 5px color-mix(in srgb, var(--sidebar-ring) 0%, transparent); + } +} + +[data-workspace-board-trigger][data-workspace-board-preview='true'] { + animation: workspace-board-trigger-preview-pulse 1.4s ease-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + [data-workspace-board-trigger][data-workspace-board-preview='true'] { + animation: none; + } +} + @keyframes workspace-kanban-sheet-in { from { clip-path: inset(0 100% 0 0); diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.tsx index fff9177bcfa..9ea74af8d77 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.tsx @@ -13,11 +13,13 @@ const WORKSPACE_BOARD_MOVED_HINT_DURATION_MS = 12000 type SidebarToolbarProps = { workspaceBoardOpen: boolean + workspaceBoardDragPreviewOpen?: boolean onWorkspaceBoardToggle: () => void } const SidebarToolbar = React.memo(function SidebarToolbar({ workspaceBoardOpen, + workspaceBoardDragPreviewOpen = false, onWorkspaceBoardToggle }: SidebarToolbarProps) { const [workspaceBoardMovedHintOpen, setWorkspaceBoardMovedHintOpen] = React.useState(false) @@ -69,7 +71,11 @@ const SidebarToolbar = React.memo(function SidebarToolbar({
diff --git a/src/renderer/src/hooks/composer-branch-selection.test.ts b/src/renderer/src/hooks/composer-branch-selection.test.ts index a61c86fae59..2d379924576 100644 --- a/src/renderer/src/hooks/composer-branch-selection.test.ts +++ b/src/renderer/src/hooks/composer-branch-selection.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { + isBranchCheckedOutInWorktrees, resolveComposerBranchNameOverrideForCreate, - resolveComposerBranchSelection + resolveComposerBranchReuse, + resolveComposerBranchSelection, + resolveComposerReuseOverride } from './composer-branch-selection' describe('resolveComposerBranchSelection', () => { @@ -76,3 +79,112 @@ describe('resolveComposerBranchSelection', () => { ).toBeUndefined() }) }) + +describe('isBranchCheckedOutInWorktrees', () => { + it('matches a branch against both refs/heads-qualified and short worktree refs', () => { + expect( + isBranchCheckedOutInWorktrees('feature-x', ['refs/heads/main', 'refs/heads/feature-x']) + ).toBe(true) + expect(isBranchCheckedOutInWorktrees('feature-x', ['feature-x'])).toBe(true) + expect(isBranchCheckedOutInWorktrees('feature-x', ['refs/heads/main', ''])).toBe(false) + expect(isBranchCheckedOutInWorktrees('feature-x', [])).toBe(false) + }) +}) + +describe('resolveComposerReuseOverride', () => { + it('keeps the selection override for a reusable (non-busy) local branch', () => { + expect( + resolveComposerReuseOverride({ + refName: 'feature-x', + localBranchName: 'feature-x', + branchNameOverride: 'feature-x', + branchCheckedOutElsewhere: false + }) + ).toBe('feature-x') + }) + + it('drops the override for a local branch checked out in another worktree', () => { + // Why: pinning a busy branch would collide and produce a suffixed branch. + expect( + resolveComposerReuseOverride({ + refName: 'feature-x', + localBranchName: 'feature-x', + branchNameOverride: 'feature-x', + branchCheckedOutElsewhere: true + }) + ).toBeUndefined() + }) + + it('keeps the override for a remote-only ref even if its local name is busy', () => { + // Why: a remote-only ref (ref !== local name) creates a fresh local tracking + // branch, so the busy check on the local name must not drop its override. + expect( + resolveComposerReuseOverride({ + refName: 'origin/feature-x', + localBranchName: 'feature-x', + branchNameOverride: 'feature-x', + branchCheckedOutElsewhere: true + }) + ).toBe('feature-x') + }) +}) + +describe('resolveComposerBranchReuse', () => { + it('marks an existing local branch reusable and defaults reuse ON for an auto-derived name', () => { + expect( + resolveComposerBranchReuse({ + refName: 'feature-x', + localBranchName: 'feature-x', + selectionProducedOverride: true, + branchCheckedOutElsewhere: false + }) + ).toEqual({ reuseEligibleBranch: 'feature-x', defaultReuse: true }) + }) + + it('treats a slash-containing local branch as reusable (ref equals local name)', () => { + expect( + resolveComposerBranchReuse({ + refName: 'fix/bug-0', + localBranchName: 'fix/bug-0', + selectionProducedOverride: true, + branchCheckedOutElsewhere: false + }) + ).toEqual({ reuseEligibleBranch: 'fix/bug-0', defaultReuse: true }) + }) + + it('does not offer reuse for a remote-only ref (ref carries an origin/ prefix)', () => { + expect( + resolveComposerBranchReuse({ + refName: 'origin/feature/something', + localBranchName: 'feature/something', + selectionProducedOverride: true, + branchCheckedOutElsewhere: false + }) + ).toEqual({ reuseEligibleBranch: null, defaultReuse: false }) + }) + + it('does not offer reuse when the branch is already checked out in another worktree', () => { + // Why: git refuses a branch in two worktrees, so reuse is impossible here. + expect( + resolveComposerBranchReuse({ + refName: 'feature-x', + localBranchName: 'feature-x', + selectionProducedOverride: true, + branchCheckedOutElsewhere: true + }) + ).toEqual({ reuseEligibleBranch: null, defaultReuse: false }) + }) + + it('keeps a local branch reuse-eligible but defaults reuse OFF when the user typed a custom name', () => { + // Why: no override means the user is branching off the ref with a custom + // worktree name; reuse stays opt-in (checkbox still shown via eligibility). + expect( + resolveComposerBranchReuse({ + refName: 'feature-x', + localBranchName: 'feature-x', + selectionProducedOverride: false, + branchCheckedOutElsewhere: false + }) + ).toEqual({ reuseEligibleBranch: 'feature-x', defaultReuse: false }) + }) +}) diff --git a/src/renderer/src/hooks/composer-branch-selection.ts b/src/renderer/src/hooks/composer-branch-selection.ts index b88fe16af78..e254ac6c933 100644 --- a/src/renderer/src/hooks/composer-branch-selection.ts +++ b/src/renderer/src/hooks/composer-branch-selection.ts @@ -1,5 +1,8 @@ export { + isBranchCheckedOutInWorktrees, resolveComposerBranchNameOverrideForCreate, + resolveComposerBranchReuse, resolveComposerBranchSelection, + resolveComposerReuseOverride, type ComposerBranchSelection } from '../../../shared/composer-branch-selection' diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index ccb3201c78b..f74a194d94d 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -151,8 +151,11 @@ import { } from '@/lib/workspace-create-error-format' import type { SshConnectionStatus } from '../../../shared/ssh-types' import { + isBranchCheckedOutInWorktrees, resolveComposerBranchNameOverrideForCreate, - resolveComposerBranchSelection + resolveComposerBranchReuse, + resolveComposerBranchSelection, + resolveComposerReuseOverride } from './composer-branch-selection' import { translate } from '@/i18n/i18n' @@ -242,6 +245,13 @@ export type ComposerCardProps = { ) => void smartNameSelection: SmartWorkspaceNameSelection | null onClearSmartNameSelection: () => void + /** True when the selected source is an existing LOCAL branch that can be + * reused (checked out) instead of branched off — gates the reuse checkbox. */ + canReuseSelectedBranch: boolean + /** Whether the selected existing local branch will be reused (checked out) + * rather than used as the base for a new branch. */ + reuseSelectedBranch: boolean + onReuseSelectedBranchChange: (next: boolean) => void agentPrompt: string onAgentPromptChange: (value: string) => void /** Rendered issueCommand template to preview inside the empty prompt @@ -810,6 +820,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [branchNameOverride, setBranchNameOverride] = useState(undefined) const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] = useState(false) + // Why (#5181): when the user picks an existing LOCAL branch, let them reuse it + // (check it out) instead of creating a new branch from it. `reuseEligibleBranch` + // is the local branch name eligible for reuse (null = not a reusable local + // branch, e.g. a remote-only ref or non-branch source); `reuseSelectedBranch` + // is the explicit checkbox value driving whether reuse actually happens. + const [reuseEligibleBranch, setReuseEligibleBranch] = useState(null) + const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false) const [pushTarget, setPushTarget] = useState(undefined) // Why: when a repo switch wipes a prior Start-from selection, surface the // reset inline (e.g. "was PR #8778") so the change is recoverable visually @@ -2143,6 +2160,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setCompareBaseRef(undefined) setPushTarget(undefined) setBranchNameOverride(undefined) + // Why (#5181): reuse state is branch-scoped, so a repo switch must clear + // it alongside the branch override (matches the other reset paths). + setBranchNameOverridePreservesNameEdits(false) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) setForkPushWarning(null) setStartFromResetHint(hint) } @@ -2214,6 +2236,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setBaseBranch(undefined) setPushTarget(undefined) setBranchNameOverride(undefined) + // Why (#5181): clear branch-scoped reuse state on a project switch too. + setBranchNameOverridePreservesNameEdits(false) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) setForkPushWarning(null) setStartFromResetHint(null) return @@ -2277,6 +2303,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setCompareBaseRef(undefined) setPushTarget(undefined) setBranchNameOverride(undefined) + // Why (#5181): the Start-from picker means "create a new branch from this + // base", so it never offers branch reuse — clear any reuse state left over + // from a prior smart-field branch pick. + setBranchNameOverridePreservesNameEdits(false) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) setForkPushWarning(null) branchAutoNameRef.current = '' setStartFromResetHint(null) @@ -2531,18 +2563,65 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setPushTarget(undefined) setStartFromResetHint(null) setForkPushWarning(null) - setBranchNameOverridePreservesNameEdits(false) + // Why (#5181): reuse an existing local branch (check it out) instead of + // branching off it. Default reuse ON when the worktree name was + // auto-derived from the branch, and preserve name edits so reuse survives + // renaming the worktree folder. Reuse is impossible when the branch is + // already checked out in another worktree (git allows it in only one), so + // gate eligibility on that and don't pin the override to a busy branch. + // Note: worktreesByRepo only covers visible worktrees; a branch busy only + // in a hidden external worktree falls through to the backend conflict + // check, which rejects it with a clear "already exists locally" error. + const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees( + localBranchName, + (worktreesByRepo[repoId] ?? []).map((worktree) => worktree.branch) + ) + const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } = + resolveComposerBranchReuse({ + refName, + localBranchName, + selectionProducedOverride: selection.branchNameOverride !== undefined, + branchCheckedOutElsewhere + }) + setReuseEligibleBranch(nextReuseEligibleBranch) + setReuseSelectedBranch(defaultReuse) + setBranchNameOverridePreservesNameEdits(defaultReuse) + const effectiveOverride = resolveComposerReuseOverride({ + refName, + localBranchName, + branchNameOverride: selection.branchNameOverride, + branchCheckedOutElsewhere + }) if (selection.name !== undefined && selection.lastAutoName !== undefined) { setName(selection.name) lastAutoNameRef.current = selection.lastAutoName - branchAutoNameRef.current = selection.branchAutoName - setBranchNameOverride(selection.branchNameOverride) + branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : '' + setBranchNameOverride(effectiveOverride) } else { - setBranchNameOverride(selection.branchNameOverride) - branchAutoNameRef.current = selection.branchAutoName + setBranchNameOverride(effectiveOverride) + branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : '' } }, - [name] + [name, worktreesByRepo, repoId] + ) + + const handleReuseSelectedBranchChange = useCallback( + (next: boolean): void => { + if (!reuseEligibleBranch) { + return + } + setReuseSelectedBranch(next) + // Why (#5181): reuse pins the exact existing branch as the override and + // preserves it across worktree-name edits, so the folder can be named + // independently while the branch is checked out. Opting out drops the + // override so a fresh branch is created from the selected ref as base. + setBranchNameOverridePreservesNameEdits(next) + setBranchNameOverride(next ? reuseEligibleBranch : undefined) + if (next) { + branchAutoNameRef.current = reuseEligibleBranch + } + }, + [reuseEligibleBranch] ) const handleSmartLinearIssueSelect = useCallback( @@ -2604,6 +2683,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setCompareBaseRef(undefined) setPushTarget(undefined) setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) setForkPushWarning(null) branchAutoNameRef.current = '' setStartFromResetHint(null) @@ -3528,6 +3610,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS smartNameGitHubSourceContext: selectedRepoGitHubSourceContext, smartNameSelection, onClearSmartNameSelection: handleClearSmartNameSelection, + canReuseSelectedBranch: + !isProjectGroupTarget && + reuseEligibleBranch !== null && + smartNameSelection?.kind === 'branch', + reuseSelectedBranch, + onReuseSelectedBranchChange: handleReuseSelectedBranchChange, agentPrompt, onAgentPromptChange: setAgentPrompt, linkedOnlyTemplatePreview: shouldApplyLinkedOnlyTemplate ? linkedOnlyTemplatePrompt : null, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c8f66aade01..25c6577d3f9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1024,6 +1024,8 @@ "d71cd3003e": "+ Assignee" }, "NewWorkspaceComposerCard": { + "reuseExistingBranch": "Reuse branch", + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.", "cbb47ee0dc": "Only available for local Git projects.", "d861de981b": "Sparse checkout", "090cfedeb4": "Write a note", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c3a59eff6ca..0866938eb9d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1076,7 +1076,9 @@ "setupKindFolder": "Folder", "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", "importingHostSetup": "Importing...", - "importHostSetup": "Import" + "importHostSetup": "Import", + "reuseExistingBranch": "Reuse branch", + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." }, "NewWorkspaceComposerModal": { "fa90f739a5": "Elija el proyecto, el nombre del espacio de trabajo y el agente antes de crear el espacio de trabajo." diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7b59e624acf..50f47f04714 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1076,7 +1076,9 @@ "setupKindFolder": "Folder", "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", "importingHostSetup": "Importing...", - "importHostSetup": "インポート" + "importHostSetup": "インポート", + "reuseExistingBranch": "Reuse branch", + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." }, "NewWorkspaceComposerModal": { "fa90f739a5": "ワークスペースを作成する前に、プロジェクト、ワークスペース名、および agent を選択します。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 147e503d8f8..919609547f4 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1076,7 +1076,9 @@ "setupKindFolder": "Folder", "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", "importingHostSetup": "Importing...", - "importHostSetup": "가져오기" + "importHostSetup": "가져오기", + "reuseExistingBranch": "Reuse branch", + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." }, "NewWorkspaceComposerModal": { "fa90f739a5": "워크스페이스를 생성하기 전에 프로젝트, 워크스페이스 이름, agent를 선택하세요." @@ -4376,10 +4378,10 @@ "5f5142a62a": "이전 아이콘" }, "AppearancePane": { + "3057983501": "할당되지 않음", "872af9556e": "시스템 트레이", "2edf606c46": "닫을 때 트레이로 최소화", "b707773a0d": "활성화하면 창을 닫아도 Orca가 종료되지 않고 시스템 트레이에서 계속 실행됩니다.", - "3057983501": "할당되지 않음", "0cd9b8228f": "Dock 및 창 전환기에 표시된 앱 아이콘을 선택합니다.", "ca1590d42f": "앱 아이콘", "61d842eca0": "사이드바에 Orca Mobile 바로가기를 표시합니다. 도구 상자에서는 계속 사용할 수 있습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 43edbc648b6..1b1569758cd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1076,7 +1076,9 @@ "setupKindFolder": "文件夹", "setupHostExistingFolderHelp": "链接已存在的检出目录,然后在该主机上创建此工作区。", "importingHostSetup": "导入中...", - "importHostSetup": "导入" + "importHostSetup": "导入", + "reuseExistingBranch": "Reuse branch", + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." }, "NewWorkspaceComposerModal": { "fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。" diff --git a/src/shared/composer-branch-selection.ts b/src/shared/composer-branch-selection.ts index a868fd66f42..4b8a0dca98d 100644 --- a/src/shared/composer-branch-selection.ts +++ b/src/shared/composer-branch-selection.ts @@ -36,6 +36,67 @@ export function resolveComposerBranchSelection(args: { } } +/** + * True when `branchName` is already checked out in one of the given worktree + * branch refs (which may be `refs/heads/foo` or short `foo`). Git refuses to + * check out a branch in two worktrees, so such a branch cannot be reused. + */ +export function isBranchCheckedOutInWorktrees( + branchName: string, + worktreeBranches: readonly string[] +): boolean { + return worktreeBranches.some((ref) => ref.replace(/^refs\/heads\//, '') === branchName) +} + +/** + * Issue #5181: decide whether a picked branch row is an existing LOCAL branch + * that can be reused (checked out) instead of branched off, and whether reuse + * should default ON. + * + * Reuse is only possible for a LOCAL branch (ref === local name; remote-only + * refs carry an `origin/`-style prefix) that is NOT already checked out in + * another worktree — git allows a branch in only one worktree at a time. Reuse + * defaults ON only when the worktree name was auto-derived from the branch (the + * selection produced a branch-name override); a user who typed a custom + * worktree name first is branching off the ref, so reuse stays OFF unless they + * opt in. + */ +export function resolveComposerBranchReuse(args: { + refName: string + localBranchName: string + selectionProducedOverride: boolean + branchCheckedOutElsewhere: boolean +}): { reuseEligibleBranch: string | null; defaultReuse: boolean } { + const reuseEligibleBranch = + args.refName === args.localBranchName && !args.branchCheckedOutElsewhere + ? args.localBranchName + : null + return { + reuseEligibleBranch, + defaultReuse: reuseEligibleBranch !== null && args.selectionProducedOverride + } +} + +/** + * Issue #5181: the branch-name override to apply for a picked branch. A local + * branch already checked out in another worktree can't be reused, so it must + * NOT be pinned as the override — pinning it would collide and silently produce + * a suffixed branch. In that case fall back to letting the worktree name derive + * a fresh branch from the selected ref as base; otherwise use the selection's + * override unchanged. + */ +export function resolveComposerReuseOverride(args: { + refName: string + localBranchName: string + branchNameOverride: string | undefined + branchCheckedOutElsewhere: boolean +}): string | undefined { + if (args.branchCheckedOutElsewhere && args.refName === args.localBranchName) { + return undefined + } + return args.branchNameOverride +} + export function resolveComposerBranchNameOverrideForCreate(args: { branchNameOverride: string | undefined branchAutoName: string From fb2f04aa4e04900c5e2350c2150e90e6dae3c2bb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:05:16 -0700 Subject: [PATCH 030/163] fix(renderer): recover from failed lazy chunk imports instead of crashing (#5803) Lazy chunk import() rejections (stale/corrupt chunk -> native SyntaxError, e.g. "Unexpected token ']'", re-thrown by React.lazy) now self-heal via lazyWithRetry: retry with backoff, one sessionStorage-guarded reload, then fall through to the error boundary (loop-safe). Adopted at every renderer lazy site. Fixes the recurring terminal.workbench / right-sidebar / page.mobile react-error-boundary crash. --- src/renderer/src/App.tsx | 2 +- src/renderer/src/app-startup-routing.test.ts | 2 +- .../src/components/GitHubItemDialog.tsx | 2 +- .../src/components/PullRequestPage.tsx | 2 +- src/renderer/src/components/Terminal.tsx | 3 +- .../crash-report/CrashReportDialog.tsx | 3 +- .../src/components/editor/ChangesModeView.tsx | 3 +- .../src/components/editor/DiffSectionBody.tsx | 3 +- .../src/components/editor/EditorContent.tsx | 3 +- .../FloatingTerminalPanel.tsx | 12 +- .../right-sidebar-panel-content.tsx | 3 +- src/renderer/src/components/sidebar/index.tsx | 9 +- .../src/components/status-bar/StatusBar.tsx | 9 +- .../components/tab-group/TabGroupPanel.tsx | 3 +- src/renderer/src/lib/lazy-with-retry.test.ts | 194 ++++++++++++++++++ src/renderer/src/lib/lazy-with-retry.ts | 114 ++++++++++ src/renderer/src/web/main.tsx | 3 +- 17 files changed, 340 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/lib/lazy-with-retry.test.ts create mode 100644 src/renderer/src/lib/lazy-with-retry.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 138405745fa..acff1427db2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,6 +1,5 @@ /* eslint-disable max-lines */ import { - lazy, Suspense, useCallback, useEffect, @@ -10,6 +9,7 @@ import { useState, type SetStateAction } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { ArrowLeft, diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 9cf833d458e..fa5802dbd57 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -161,7 +161,7 @@ describe('renderer startup runtime routing', () => { expect(appSource).toContain('boundaryId="modal.confirm-add-project-from-folder"') expect(appSource).toContain('boundaryId="modal.project-added"') expect(appSource).toContain('setTimeout(() =>') - expect(sidebarSource).toContain("React.lazy(() => import('./WorktreeMetaDialog'))") + expect(sidebarSource).toContain("lazyWithRetry(() => import('./WorktreeMetaDialog'))") expect(sidebarSource).not.toContain("from './AddRepoDialog'") expect(sidebarSource).not.toContain("React.lazy(() => import('./AddRepoDialog'))") expect(sidebarSource).not.toContain("React.lazy(() => import('./NonGitFolderDialog'))") diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index c0522afddcf..f2c12955447 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -1,7 +1,6 @@ /* eslint-disable max-lines -- Why: the GH item dialog keeps its header, conversation, files, and checks tabs co-located so the read-only PR/Issue surface stays in one place while this view evolves. */ import React, { Suspense, - lazy, useCallback, useEffect, useLayoutEffect, @@ -10,6 +9,7 @@ import React, { useState, useSyncExternalStore } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { useVirtualizer } from '@tanstack/react-virtual' import { useShallow } from 'zustand/react/shallow' import type { editor as monacoEditor } from 'monaco-editor' diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 4154b4e482e..053ff6265e1 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -1,7 +1,6 @@ /* eslint-disable max-lines -- Why: duplicated from GitHubItemDialog so the dedicated PR full-page surface can evolve its Primer-styled header without destabilizing the issue dialog; planned to refactor shared parts out later. */ import React, { Suspense, - lazy, useCallback, useEffect, useLayoutEffect, @@ -10,6 +9,7 @@ import React, { useState, useSyncExternalStore } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { useVirtualizer } from '@tanstack/react-virtual' import { useShallow } from 'zustand/react/shallow' import type { editor as monacoEditor } from 'monaco-editor' diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 1c41f158c1e..905d18c3bbe 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1,6 +1,7 @@ /* eslint-disable max-lines */ -import React, { useEffect, useCallback, useMemo, useRef, useState, lazy, Suspense } from 'react' +import React, { useEffect, useCallback, useMemo, useRef, useState, Suspense } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { createPortal } from 'react-dom' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' diff --git a/src/renderer/src/components/crash-report/CrashReportDialog.tsx b/src/renderer/src/components/crash-report/CrashReportDialog.tsx index de66ea79796..fe2ba9b9024 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialog.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialog.tsx @@ -1,4 +1,5 @@ -import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react' +import { Suspense, useCallback, useEffect, useRef, useState } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { useMountedRef } from '@/hooks/useMountedRef' import { REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT, diff --git a/src/renderer/src/components/editor/ChangesModeView.tsx b/src/renderer/src/components/editor/ChangesModeView.tsx index aa472de07aa..27d2ce943b3 100644 --- a/src/renderer/src/components/editor/ChangesModeView.tsx +++ b/src/renderer/src/components/editor/ChangesModeView.tsx @@ -1,4 +1,5 @@ -import React, { lazy } from 'react' +import React from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import type { OpenFile } from '@/store/slices/editor' import type { GitDiffResult, GitStatusEntry } from '../../../../shared/types' import { ConflictBanner } from './ConflictComponents' diff --git a/src/renderer/src/components/editor/DiffSectionBody.tsx b/src/renderer/src/components/editor/DiffSectionBody.tsx index 0c3200c1841..7792e902e17 100644 --- a/src/renderer/src/components/editor/DiffSectionBody.tsx +++ b/src/renderer/src/components/editor/DiffSectionBody.tsx @@ -1,4 +1,5 @@ -import { lazy, type RefObject } from 'react' +import { type RefObject } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { AlertCircle, RefreshCw } from 'lucide-react' import { DiffEditor, type DiffOnMount } from '@monaco-editor/react' import { cn } from '@/lib/utils' diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index e045f77189a..bc6d64e5a58 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -4,7 +4,8 @@ now Changes view mode). Keeping the mode-selection branches colocated is easier to reason about than scattering the switch across per-mode wrappers. Individual renderers (MonacoEditor, DiffViewer, ChangesModeView, MarkdownPreview, etc.) already live in their own modules. */ -import React, { lazy } from 'react' +import React from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { AlertCircle, RefreshCw } from 'lucide-react' import { detectLanguage } from '@/lib/language-detect' import { joinPath } from '@/lib/path' diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 88197dbce8a..d4919c0b55f 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -2,16 +2,8 @@ * resizing, orchestration setup, and mixed terminal/browser/editor tab * handling in one surface so the floating worktree does not drift from the * main tab model while still keeping the DOM-mounted panes local. */ -import { - lazy, - Suspense, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState -} from 'react' +import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { FileText, Globe, Minus, TerminalSquare } from 'lucide-react' import { toast } from 'sonner' import BrowserPane from '@/components/browser-pane/BrowserPane' diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx index 8e1e6c21894..f26f427152e 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx @@ -1,4 +1,5 @@ -import { lazy, Suspense } from 'react' +import { Suspense } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import type { ActiveRightSidebarTab } from '@/store/slices/editor' const FileExplorer = lazy(() => import('./FileExplorer')) diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index fd6801d27b2..a980f9cf8f8 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -15,11 +15,12 @@ import { useSidebarProjectDrop } from './useSidebarProjectDrop' import { useWorkspaceBoardPanel } from './useWorkspaceBoardPanel' import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance' import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark' +import { lazyWithRetry } from '@/lib/lazy-with-retry' -const WorktreeMetaDialog = React.lazy(() => import('./WorktreeMetaDialog')) -const RemoveFolderDialog = React.lazy(() => import('./RemoveFolderDialog')) -const WorktreeVisibilityDialog = React.lazy(() => import('./WorktreeVisibilityDialog')) -const OrcaYamlTrustDialog = React.lazy(() => import('./OrcaYamlTrustDialog')) +const WorktreeMetaDialog = lazyWithRetry(() => import('./WorktreeMetaDialog')) +const RemoveFolderDialog = lazyWithRetry(() => import('./RemoveFolderDialog')) +const WorktreeVisibilityDialog = lazyWithRetry(() => import('./WorktreeVisibilityDialog')) +const OrcaYamlTrustDialog = lazyWithRetry(() => import('./OrcaYamlTrustDialog')) const MIN_WIDTH = 220 const MAX_WIDTH = 500 diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index b67aa4cfa7d..6a4083f4bc0 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -14,6 +14,7 @@ import { Server } from 'lucide-react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { lazyWithRetry } from '@/lib/lazy-with-retry' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { @@ -70,18 +71,18 @@ type StatusBarProps = { floatingTerminalOpen: boolean } -const PetStatusSegment = React.lazy(() => +const PetStatusSegment = lazyWithRetry(() => import('./PetStatusSegment').then((module) => ({ default: module.PetStatusSegment })) ) -const ResourceUsageStatusSegment = React.lazy(() => +const ResourceUsageStatusSegment = lazyWithRetry(() => import('./ResourceUsageStatusSegment').then((module) => ({ default: module.ResourceUsageStatusSegment })) ) -const PortsStatusSegment = React.lazy(() => +const PortsStatusSegment = lazyWithRetry(() => import('./PortsStatusSegment').then((module) => ({ default: module.PortsStatusSegment })) ) -const SshStatusSegment = React.lazy(() => +const SshStatusSegment = lazyWithRetry(() => import('./SshStatusSegment').then((module) => ({ default: module.SshStatusSegment })) ) diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 4ad1eeaf382..668fbb8da92 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -1,4 +1,5 @@ -import { lazy, Suspense, useMemo } from 'react' +import { Suspense, useMemo } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { useDroppable } from '@dnd-kit/core' import { Columns2, Ellipsis, Rows2, X } from 'lucide-react' import { useAppStore } from '../../store' diff --git a/src/renderer/src/lib/lazy-with-retry.test.ts b/src/renderer/src/lib/lazy-with-retry.test.ts new file mode 100644 index 00000000000..7ef2f26fdec --- /dev/null +++ b/src/renderer/src/lib/lazy-with-retry.test.ts @@ -0,0 +1,194 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ComponentType } from 'react' + +import { loadLazyWithRetry } from './lazy-with-retry' + +const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' +const Comp: ComponentType = () => null +const chunkParseError = (): SyntaxError => new SyntaxError("Unexpected token ']'") + +function spyOnReload(): ReturnType { + const reload = vi.fn() + // happy-dom's location.reload is a no-op that would otherwise log; replace it. + vi.spyOn(window.location, 'reload').mockImplementation(reload) + return reload +} + +function stubCrashReportsBreadcrumb(): ReturnType { + const recordBreadcrumb = vi.fn() + Object.assign(window, { api: { crashReports: { recordBreadcrumb } } }) + return recordBreadcrumb +} + +// Why: happy-dom's Storage is a Proxy that vi.spyOn cannot reliably restore, so +// override window.sessionStorage with a throwing getter and restore the saved +// descriptor in afterEach. +let savedSessionStorageDescriptor: PropertyDescriptor | undefined + +function makeSessionStorageThrow(): void { + savedSessionStorageDescriptor = Object.getOwnPropertyDescriptor(window, 'sessionStorage') + Object.defineProperty(window, 'sessionStorage', { + configurable: true, + get() { + throw new Error('storage blocked') + } + }) +} + +beforeEach(() => { + vi.useFakeTimers() + window.sessionStorage.clear() +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + vi.useRealTimers() + if (savedSessionStorageDescriptor) { + Object.defineProperty(window, 'sessionStorage', savedSessionStorageDescriptor) + savedSessionStorageDescriptor = undefined + } + try { + delete (window as unknown as { api?: unknown }).api + window.sessionStorage.clear() + } catch { + // ignore — environment without storage + } +}) + +describe('loadLazyWithRetry', () => { + it('retries with exponential backoff (250ms, 500ms) and then resolves', async () => { + const reload = spyOnReload() + const factory = vi + .fn() + .mockRejectedValueOnce(chunkParseError()) + .mockRejectedValueOnce(chunkParseError()) + .mockResolvedValueOnce({ default: Comp }) + + const loaded = loadLazyWithRetry(factory, { retries: 2, baseDelayMs: 250 }) + expect(factory).toHaveBeenCalledTimes(1) // first attempt runs synchronously + + await vi.advanceTimersByTimeAsync(200) + expect(factory).toHaveBeenCalledTimes(1) // still inside the 250ms backoff + await vi.advanceTimersByTimeAsync(100) + expect(factory).toHaveBeenCalledTimes(2) // 250ms elapsed -> 2nd attempt + + await vi.advanceTimersByTimeAsync(400) + expect(factory).toHaveBeenCalledTimes(2) // still inside the 500ms backoff + await vi.advanceTimersByTimeAsync(100) + expect(factory).toHaveBeenCalledTimes(3) // 500ms elapsed -> 3rd attempt + + await expect(loaded).resolves.toEqual({ default: Comp }) + expect(reload).not.toHaveBeenCalled() + }) + + it('performs exactly one guarded reload after retries are exhausted', async () => { + const reload = spyOnReload() + const factory = vi.fn(() => Promise.reject(chunkParseError())) + + const loaded = loadLazyWithRetry(factory, { retries: 2, baseDelayMs: 250 }) + let settled = false + void loaded.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await vi.advanceTimersByTimeAsync(5000) + + expect(factory).toHaveBeenCalledTimes(3) + expect(reload).toHaveBeenCalledTimes(1) + expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBe('1') + // The load promise must suspend (never settle) while the page reloads, so the + // error boundary never flashes. + expect(settled).toBe(false) + }) + + it('does NOT reload twice — re-throws once the guard is already set', async () => { + const reload = spyOnReload() + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const error = chunkParseError() + const factory = vi.fn(() => Promise.reject(error)) + + const loaded = loadLazyWithRetry(factory, { retries: 2, baseDelayMs: 250 }) + const assertion = expect(loaded).rejects.toBe(error) + await vi.advanceTimersByTimeAsync(5000) + await assertion + + expect(reload).not.toHaveBeenCalled() + }) + + it('fails closed (re-throws, never reloads) when sessionStorage reads throw', async () => { + const reload = spyOnReload() + // Private-mode / sandboxed storage makes reads throw. The guard must treat + // this as "already reloaded" so a broken chunk can NEVER cause a reload loop. + makeSessionStorageThrow() + const error = chunkParseError() + const factory = vi.fn(() => Promise.reject(error)) + + const loaded = loadLazyWithRetry(factory, { retries: 1, baseDelayMs: 100 }) + const assertion = expect(loaded).rejects.toBe(error) + await vi.advanceTimersByTimeAsync(5000) + await assertion + + expect(reload).not.toHaveBeenCalled() + }) + + it('records a lazy_chunk_reload breadcrumb (with reloadKey) before reloading', async () => { + const reload = spyOnReload() + const recordBreadcrumb = stubCrashReportsBreadcrumb() + const factory = vi.fn(() => Promise.reject(chunkParseError())) + + const loaded = loadLazyWithRetry(factory, { retries: 0, reloadKey: 'right-sidebar' }) + let settled = false + void loaded.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await vi.advanceTimersByTimeAsync(5000) + + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + expect(recordBreadcrumb).toHaveBeenCalledWith({ + name: 'lazy_chunk_reload', + data: { reloadKey: 'right-sidebar', message: "Unexpected token ']'" } + }) + // The breadcrumb must land before window.location.reload() tears the page down. + expect(recordBreadcrumb.mock.invocationCallOrder[0]).toBeLessThan( + reload.mock.invocationCallOrder[0] + ) + expect(settled).toBe(false) + }) + + it('re-throws without reloading when there is no window (SSR / node)', async () => { + vi.stubGlobal('window', undefined) + const error = chunkParseError() + const factory = vi.fn(() => Promise.reject(error)) + + const loaded = loadLazyWithRetry(factory, { retries: 1, baseDelayMs: 100 }) + const assertion = expect(loaded).rejects.toBe(error) + await vi.advanceTimersByTimeAsync(5000) + await assertion + + expect(factory).toHaveBeenCalledTimes(2) + }) + + it('keeps the reload guard set across a successful load (no second reload in one session)', async () => { + const reload = spyOnReload() + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const factory = vi.fn(() => Promise.resolve({ default: Comp })) + + await loadLazyWithRetry(factory) + + // The guard must survive a healthy load — otherwise a sibling chunk's success + // would re-arm the reload and an auto-mounted corrupt chunk would loop. + expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBe('1') + expect(reload).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/lazy-with-retry.ts b/src/renderer/src/lib/lazy-with-retry.ts new file mode 100644 index 00000000000..5a09f6b2a95 --- /dev/null +++ b/src/renderer/src/lib/lazy-with-retry.ts @@ -0,0 +1,114 @@ +import { lazy, type ComponentType, type LazyExoticComponent } from 'react' + +/** + * Resilient replacement for React.lazy. + * + * Why: a stale, corrupt, or truncated lazy chunk parses as invalid JavaScript and + * rejects its dynamic import() with a native SyntaxError (e.g. "Unexpected token + * ']'"). React.lazy permanently caches that rejection, so the error boundary's + * "Retry" — which just re-renders the same Lazy — can never recover it; the + * surface stays dead and reports a react-error-boundary crash. This wrapper first + * retries transient fetch failures, then performs ONE guarded full reload to + * refetch fresh chunk bytes and rebuild the ES module map, before finally falling + * through to the error boundary. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirror React.lazy's own ComponentType constraint so every existing call site type-checks unchanged. +type AnyComponent = ComponentType + +type LazyFactory = () => Promise<{ default: T }> + +export type LazyWithRetryOptions = { + retries?: number + baseDelayMs?: number + /** Label surfaced in the reload breadcrumb for triage; not used for control flow. */ + reloadKey?: string +} + +// One recovery reload per session. The guard survives the reload itself (so we +// never loop) but resets when the window/app closes, so a later launch — e.g. +// after an update ships fresh chunks — can earn another reload. sessionStorage +// (not localStorage) gives exactly that lifetime; it is never cleared mid-session, +// otherwise a sibling chunk's healthy load would re-arm the reload and an +// auto-mounted corrupt chunk would loop. +const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' +const DEFAULT_RETRIES = 2 +const DEFAULT_BASE_DELAY_MS = 250 + +function hasAttemptedChunkReload(): boolean { + try { + return window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1' + } catch { + // Why: when sessionStorage is unavailable (private mode / sandboxed), fail + // closed (treat as already-reloaded) so we never risk an infinite reload loop. + return true + } +} + +function markChunkReloadAttempted(): void { + try { + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + } catch { + // Best-effort; if writing throws, hasAttemptedChunkReload() also fails closed. + } +} + +function recordReloadBreadcrumb(reloadKey: string, message: string): void { + // Inlined rather than importing crash-diagnostics so this low-level recovery + // primitive stays free of the renderer/webview module graph (keeps it SSR- and + // unit-test-friendly). Mirrors crash-diagnostics' best-effort breadcrumb call. + try { + const api = (window as Window & { api?: Window['api'] }).api + api?.crashReports.recordBreadcrumb({ name: 'lazy_chunk_reload', data: { reloadKey, message } }) + } catch { + // Crash evidence is best-effort and must never mask the original failure. + } +} + +const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +// Suspends the React.lazy boundary while window.location.reload() tears the page +// down, so the error fallback never flashes in the moment before the reload lands. +const SUSPEND_UNTIL_RELOAD = new Promise(() => undefined) + +export async function loadLazyWithRetry( + factory: LazyFactory, + options: LazyWithRetryOptions = {} +): Promise<{ default: T }> { + const retries = options.retries ?? DEFAULT_RETRIES + const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS + + let lastError: unknown + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + return await factory() + } catch (error) { + lastError = error + if (attempt < retries) { + // Exponential backoff absorbs transient fetch hiccups (HTTP / relay / SSH). + await wait(baseDelayMs * 2 ** attempt) + } + } + } + + if (typeof window !== 'undefined' && !hasAttemptedChunkReload()) { + markChunkReloadAttempted() + recordReloadBreadcrumb( + options.reloadKey ?? 'unknown', + lastError instanceof Error ? lastError.message : String(lastError) + ) + window.location.reload() + return SUSPEND_UNTIL_RELOAD + } + + // Already reloaded once this session, or no window (SSR / node): re-throw so + // RecoverableRenderErrorBoundary catches and reports it instead of looping. + throw lastError +} + +export function lazyWithRetry( + factory: LazyFactory, + options?: LazyWithRetryOptions +): LazyExoticComponent { + return lazy(() => loadLazyWithRetry(factory, options)) +} diff --git a/src/renderer/src/web/main.tsx b/src/renderer/src/web/main.tsx index 7cfd3abec91..da0cda067d5 100644 --- a/src/renderer/src/web/main.tsx +++ b/src/renderer/src/web/main.tsx @@ -1,6 +1,7 @@ import '../assets/main.css' -import { lazy, Suspense, useMemo, useState } from 'react' +import { Suspense, useMemo, useState } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import ReactDOM from 'react-dom/client' import { useTranslation } from 'react-i18next' import WebConnect from './WebConnect' From bef62a73a53441f00567ff722c7c2eea090492af Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:26:16 -0700 Subject: [PATCH 031/163] fix(mobile-emulator): keep "+" menu open on intro actions and auto-dismiss hidden toast (#5800) The Mobile Emulator intro callout in the new-tab ("+") menu closed the whole dropdown when Keep/Hide/Dismiss was clicked, and the "Mobile Emulator hidden" toast used duration: Infinity so it never went away. - Drop the onAction/runAndNotify menu-close wiring so the callout actions leave the dropdown open; removing the prop makes reintroducing the close a type error. - Auto-dismiss the toast after 30s (still dismissible). - Make the new-tab dropdown modal={false} so the toast's "Settings > Mobile Emulator" re-enable link stays clickable while the menu remains open. Tests: callout button wiring + pointer-down guard + compile-time no-prop guard; toast duration unit test; updated intro-actions test asserts the 30s duration. --- .../MobileEmulatorTabIntroCallout.test.tsx | 102 ++++++++++++++++++ .../MobileEmulatorTabIntroCallout.tsx | 22 ++-- .../mobile-emulator-hidden-toast.test.tsx | 37 +++++++ .../mobile-emulator-hidden-toast.tsx | 7 +- ...mobile-emulator-tab-intro-actions.test.tsx | 2 +- .../src/components/tab-bar/TabBar.tsx | 11 +- 6 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx create mode 100644 src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx diff --git a/src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx b/src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx new file mode 100644 index 00000000000..f5ccbbd2019 --- /dev/null +++ b/src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileEmulatorTabIntroCallout } from './MobileEmulatorTabIntroCallout' + +const { keepIntro, hideIntro, dismissIntro } = vi.hoisted(() => ({ + keepIntro: vi.fn(), + hideIntro: vi.fn(), + dismissIntro: vi.fn() +})) + +vi.mock('./use-mobile-emulator-tab-intro-actions', () => ({ + useMobileEmulatorTabIntroActions: () => ({ keepIntro, hideIntro, dismissIntro }) +})) + +// Why: the Tooltip primitive needs a provider/portal we don't care about here; +// render its trigger children directly so the test stays focused on wiring. +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => children, + TooltipContent: () => null +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +beforeEach(async () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) +}) + +afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + vi.clearAllMocks() +}) + +function click(matcher: string): void { + const button = Array.from(container?.querySelectorAll('button') ?? []).find( + (el) => el.textContent?.trim() === matcher || el.getAttribute('aria-label') === matcher + ) + if (!button) { + throw new Error(`button not found: ${matcher}`) + } + act(() => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +describe('MobileEmulatorTabIntroCallout', () => { + it('runs keepIntro when Keep is clicked', () => { + click('Keep') + expect(keepIntro).toHaveBeenCalledTimes(1) + expect(hideIntro).not.toHaveBeenCalled() + expect(dismissIntro).not.toHaveBeenCalled() + }) + + it('runs hideIntro when Hide is clicked', () => { + click('Hide') + expect(hideIntro).toHaveBeenCalledTimes(1) + expect(keepIntro).not.toHaveBeenCalled() + expect(dismissIntro).not.toHaveBeenCalled() + }) + + it('runs dismissIntro when the X is clicked', () => { + click('Dismiss') + expect(dismissIntro).toHaveBeenCalledTimes(1) + expect(keepIntro).not.toHaveBeenCalled() + expect(hideIntro).not.toHaveBeenCalled() + }) + + it('exposes no menu-closing prop, so reintroducing Issue 1 is a compile error', () => { + // Why: Issue 1's root cause was a parent-supplied close callback + // (onAction={() => setNewTabMenuOpen(false)}). The callout must accept no + // such prop — re-adding one fails this typechecked file's @ts-expect-error. + // @ts-expect-error - MobileEmulatorTabIntroCallout intentionally takes no props + const reintroduced = undefined} /> + expect(reintroduced).toBeDefined() + }) + + it('prevents the default pointer-down so the dropdown menu stays open', () => { + const callout = container?.querySelector('.mobile-emulator-tab-intro-callout--menu') + expect(callout).not.toBeNull() + const event = new MouseEvent('pointerdown', { bubbles: true, cancelable: true }) + act(() => { + callout?.dispatchEvent(event) + }) + expect(event.defaultPrevented).toBe(true) + }) +}) diff --git a/src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.tsx b/src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.tsx index 2d1924737c6..e97bdaab1da 100644 --- a/src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.tsx +++ b/src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.tsx @@ -4,25 +4,15 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { translate } from '@/i18n/i18n' import { useMobileEmulatorTabIntroActions } from './use-mobile-emulator-tab-intro-actions' -type MobileEmulatorTabIntroCalloutProps = { - onAction?: () => void -} - -export function MobileEmulatorTabIntroCallout({ - onAction -}: MobileEmulatorTabIntroCalloutProps): React.JSX.Element { +export function MobileEmulatorTabIntroCallout(): React.JSX.Element { const { keepIntro, hideIntro, dismissIntro } = useMobileEmulatorTabIntroActions() - const runAndNotify = (action: () => void): void => { - action() - onAction?.() - } - return (
event.preventDefault()} >

@@ -37,7 +27,7 @@ export function MobileEmulatorTabIntroCallout({ size="sm" variant="outline" className="h-6 px-2 text-[11px]" - onClick={() => runAndNotify(keepIntro)} + onClick={keepIntro} > {translate( 'auto.components.emulator.pane.MobileEmulatorTabIntroCallout.8014b4b80b', @@ -49,7 +39,7 @@ export function MobileEmulatorTabIntroCallout({ size="sm" variant="ghost" className="h-6 px-2 text-[11px] text-muted-foreground" - onClick={() => runAndNotify(hideIntro)} + onClick={hideIntro} > {translate( 'auto.components.emulator.pane.MobileEmulatorTabIntroCallout.6e051a40b7', @@ -67,7 +57,7 @@ export function MobileEmulatorTabIntroCallout({ 'Dismiss' )} className="size-6 text-muted-foreground" - onClick={() => runAndNotify(dismissIntro)} + onClick={dismissIntro} > diff --git a/src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx b/src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx new file mode 100644 index 00000000000..9ec3335571c --- /dev/null +++ b/src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import type { AppState } from '@/store/types' +import { showMobileEmulatorHiddenToast } from './mobile-emulator-hidden-toast' + +vi.mock('sonner', () => ({ + toast: { + dismiss: vi.fn(), + info: vi.fn() + } +})) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('showMobileEmulatorHiddenToast', () => { + it('auto-dismisses after 30s while staying dismissible', () => { + showMobileEmulatorHiddenToast({ + openSettingsPage: vi.fn() as unknown as AppState['openSettingsPage'], + openSettingsTarget: vi.fn() as unknown as AppState['openSettingsTarget'] + }) + + const infoMock = vi.mocked(toast.info) + expect(infoMock).toHaveBeenCalledTimes(1) + const options = infoMock.mock.calls[0]?.[1] + // Why: a finite duration is the fix for the toast lingering forever; assert + // the exact value so a regression to Infinity is caught. + expect(options).toMatchObject({ + id: 'mobile-emulator-hidden', + duration: 30_000, + dismissible: true + }) + }) +}) diff --git a/src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.tsx b/src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.tsx index d73a0ccdc68..d62b44f9f2f 100644 --- a/src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.tsx +++ b/src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.tsx @@ -3,6 +3,9 @@ import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' const MOBILE_EMULATOR_HIDDEN_TOAST_ID = 'mobile-emulator-hidden' +// Why: auto-dismiss the nudge after 30s so it can't linger forever; it stays +// dismissible early and the Settings re-enable link is reachable until then. +const MOBILE_EMULATOR_HIDDEN_TOAST_DURATION_MS = 30_000 type MobileEmulatorHiddenToastDeps = { openSettingsPage: AppState['openSettingsPage'] @@ -10,8 +13,6 @@ type MobileEmulatorHiddenToastDeps = { } export function showMobileEmulatorHiddenToast(deps: MobileEmulatorHiddenToastDeps): void { - // Why: matches other one-time opt-out nudges — stay on screen until the user - // dismisses it so the Settings re-enable path is easy to find. toast.info( translate( 'auto.components.emulator.pane.mobile.emulator.hidden.toast.e8f098a870', @@ -42,7 +43,7 @@ export function showMobileEmulatorHiddenToast(deps: MobileEmulatorHiddenToastDep .

), - duration: Infinity, + duration: MOBILE_EMULATOR_HIDDEN_TOAST_DURATION_MS, dismissible: true } ) diff --git a/src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx b/src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx index f83485ff173..137c20f63bc 100644 --- a/src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx +++ b/src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx @@ -121,7 +121,7 @@ describe('useMobileEmulatorTabIntroActions', () => { expect(closeUnifiedTab).toHaveBeenCalledWith('simulator-tab') expect(toast.info).toHaveBeenCalledWith( 'Mobile Emulator hidden', - expect.objectContaining({ id: 'mobile-emulator-hidden' }) + expect.objectContaining({ id: 'mobile-emulator-hidden', duration: 30_000 }) ) expect(toast.error).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 5f5982f4968..fc7008ae672 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -769,7 +769,7 @@ function TabBarInner({ isMacOs && mobileEmulatorEnabled && onNewSimulatorTab ? ( - setNewTabMenuOpen(false)} /> + ) : null const standardCreateMenuItems = newTabMenuOrder === 'markdown-first' ? ( @@ -1193,7 +1193,14 @@ function TabBarInner({ ) : null} - +
- {!isCloud && isReady && !isActive ? ( - + {deletePending ? ( + + ) : ( + + )} + ) : !isCloud && !isReady && !isDownloading ? ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 25c6577d3f9..be7d84706ff 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6277,6 +6277,7 @@ "c2c64b24d0": "Reset" }, "VoicePane": { + "6fa734ed95": "Delete {{value0}}", "68de13f72c": "Failed to delete model.", "1ba81c0ff0": "recommended", "cfde55c7b0": "Failed to download model.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0866938eb9d..a0a53a4a9a7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -6268,7 +6268,8 @@ "ad5d036ecc": "No se pudo solicitar permiso del micrófono. El dictado de voz no estaba habilitado.", "f9a9cf6928": "Se requiere permiso del micrófono antes de habilitar el dictado de voz.", "1eac933202": "Se abrió Privacidad y seguridad de macOS. Habilite el dictado nuevamente después de otorgar acceso.", - "cd9fe37556": "Permiso de micrófono concedido" + "cd9fe37556": "Permiso de micrófono concedido", + "6fa734ed95": "Delete {{value0}}" }, "WorktreeSymlinksSection": { "1c1e35b219": "Quitar {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 50f47f04714..e7ce8ab1d16 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6290,7 +6290,8 @@ "ad5d036ecc": "マイクの許可を要求できませんでした。音声ディクテーションが有効になっていませんでした。", "f9a9cf6928": "音声ディクテーションを有効にする前に、マイクの許可が必要です。", "1eac933202": "macOS のプライバシーとセキュリティを開きました。アクセスを許可した後、ディクテーションを再度有効にします。", - "cd9fe37556": "マイクの許可が与えられました" + "cd9fe37556": "マイクの許可が与えられました", + "6fa734ed95": "Delete {{value0}}" }, "WorktreeSymlinksSection": { "1c1e35b219": "{{value0}} を削除", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 919609547f4..aafb68a9a77 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6253,7 +6253,8 @@ "ad5d036ecc": "마이크 권한을 요청할 수 없습니다. 음성 받아쓰기가 활성화되지 않았습니다.", "f9a9cf6928": "음성 받아쓰기를 활성화하려면 마이크 권한이 필요합니다.", "1eac933202": "macOS 개인 정보 보호 및 보안을 열었습니다. 액세스 권한을 부여한 후 받아쓰기를 다시 활성화하세요.", - "cd9fe37556": "마이크 권한이 부여되었습니다." + "cd9fe37556": "마이크 권한이 부여되었습니다.", + "6fa734ed95": "Delete {{value0}}" }, "WorktreeSymlinksSection": { "1c1e35b219": "{{value0}} 제거", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1b1569758cd..1cea91547dc 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6253,7 +6253,8 @@ "ad5d036ecc": "无法请求麦克风权限。未启用语音听写。", "f9a9cf6928": "启用语音听写之前需要麦克风许可。", "1eac933202": "打开 macOS 隐私和安全。授予访问权限后再次启用听写。", - "cd9fe37556": "已授予麦克风权限" + "cd9fe37556": "已授予麦克风权限", + "6fa734ed95": "Delete {{value0}}" }, "WorktreeSymlinksSection": { "1c1e35b219": "删除 {{value0}}", From d90366f7203831b3fd370077b668add543639c7c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:33:17 -0700 Subject: [PATCH 035/163] fix(tab-bar): search agents by prefix and make the open-entry list keyboard-navigable (#5817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Open any file, URL, agent, ..." new-tab entry had two gaps: 1. Agents were not really searchable. findMatchingTabAgentLaunchOptions only matched on exact alias equality, so a partial name ("gemin", "clau") surfaced no agent. It now scores prefix/token matches (exact alias wins; otherwise every query token must prefix an alias token; mid-string and single-char hits are rejected so agents — which rank above file results — don't noisily hijack the list). The tokenizer is shared with the create-menu matcher via query-token-match.ts. 2. Arrow keys didn't navigate the list. Navigation only ran when there were >1 result rows, and the just-opened (empty-query) state trapped focus in the input so the static create/agent list below was unreachable. Arrows now move the highlight for any result count, and with no result rows focus routes into the enclosing Radix menu; ArrowUp on the first item returns to the search box so the trip isn't one-way. Also adds combobox/listbox + aria-activedescendant semantics so the highlight is exposed to assistive tech, and splits the row presentation and option types into focused modules to keep the component under the line cap. --- .../TabBarCreateEntry.keyboard.test.tsx | 220 ++++++++++++++++ .../components/tab-bar/TabBarCreateEntry.tsx | 244 +++++++----------- .../tab-bar/TabBarCreateEntryRow.tsx | 131 ++++++++++ .../tab-bar/query-token-match.test.ts | 31 +++ .../components/tab-bar/query-token-match.ts | 51 ++++ .../tab-bar/tab-agent-launch-options.test.ts | 44 ++++ .../tab-bar/tab-agent-launch-options.ts | 55 +++- .../tab-bar/tab-create-entry-active-option.ts | 37 +++ .../tab-bar/tab-create-menu-options.ts | 52 +--- 9 files changed, 656 insertions(+), 209 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx create mode 100644 src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx create mode 100644 src/renderer/src/components/tab-bar/query-token-match.test.ts create mode 100644 src/renderer/src/components/tab-bar/query-token-match.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-active-option.ts diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx new file mode 100644 index 00000000000..ffd81e96c09 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx @@ -0,0 +1,220 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TabEntryOption } from './tab-create-entry-action' +import type { TabAgentLaunchOption } from './tab-agent-launch-options' + +// Why: the real entry-action module pulls in runtime IPC + the app store; the +// keyboard behavior under test only needs a controllable option list. +const entryOptionsMock = vi.hoisted(() => ({ options: [] as TabEntryOption[] })) +vi.mock('./tab-create-entry-action', () => ({ + getTabEntryOptions: () => entryOptionsMock.options +})) +vi.mock('../quick-open-file-list', () => ({ + useRuntimeFileListForWorktree: () => ({ files: [], loading: false, loadError: null }) +})) +vi.mock('@/lib/agent-catalog', () => ({ + getAgentCatalog: () => [], + AgentIcon: () => null +})) + +import TabBarCreateEntry from './TabBarCreateEntry' + +// Why: opt into React's act environment so state updates flush synchronously. +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const fileOption = (relativePath: string): TabEntryOption => ({ + id: `existing-file:${relativePath}`, + classification: { kind: 'existing-file', matchKind: 'fuzzy', relativePath } +}) + +let container: HTMLDivElement +let root: Root + +function mount(node: React.JSX.Element): void { + act(() => { + root.render(node) + }) +} + +function pressKey(target: Element, key: string): KeyboardEvent { + const event = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + act(() => { + target.dispatchEvent(event) + }) + return event +} + +function setQuery(value: string): void { + const input = container.querySelector('input') + if (!input) { + throw new Error('input not found') + } + // Why: React patches the input value setter to track changes; bypass it with + // the native setter so the synthetic onChange actually fires. + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + act(() => { + nativeSetter?.call(input, value) + input.dispatchEvent(new window.Event('input', { bubbles: true })) + }) +} + +function submitForm(): void { + const form = container.querySelector('form') + if (!form) { + throw new Error('form not found') + } + act(() => { + form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })) + }) +} + +beforeEach(() => { + entryOptionsMock.options = [] + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +describe('TabBarCreateEntry keyboard navigation', () => { + it('intercepts ArrowDown on a single-option list so it does not leak (guards >0 vs >1)', () => { + entryOptionsMock.options = [fileOption('src/only-match.ts')] + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + mount() + + // The pre-fix `> 1` guard left a single result unhandled: the arrow leaked to + // the input cursor / Radix. Now it must be consumed (preventDefault) and Enter + // still opens the row. + const event = pressKey(container.querySelector('input')!, 'ArrowDown') + expect(event.defaultPrevented).toBe(true) + + submitForm() + expect(onOpenEntry).toHaveBeenCalledTimes(1) + expect(onOpenEntry.mock.calls[0][0].classification).toMatchObject({ + kind: 'existing-file', + relativePath: 'src/only-match.ts' + }) + }) + + it('iterates a multi-option result list with ArrowDown and selects the highlighted row', () => { + entryOptionsMock.options = [fileOption('a.ts'), fileOption('b.ts'), fileOption('c.ts')] + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + mount() + + const input = container.querySelector('input')! + pressKey(input, 'ArrowDown') + pressKey(input, 'ArrowDown') + submitForm() + + expect(onOpenEntry.mock.calls[0][0].classification).toMatchObject({ relativePath: 'c.ts' }) + }) + + it('wraps from the first row to the last with ArrowUp', () => { + entryOptionsMock.options = [fileOption('a.ts'), fileOption('b.ts')] + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + mount() + + pressKey(container.querySelector('input')!, 'ArrowUp') + submitForm() + + expect(onOpenEntry.mock.calls[0][0].classification).toMatchObject({ relativePath: 'b.ts' }) + }) + + it('launches a matched agent when its highlighted row is selected', () => { + const agentOptions: TabAgentLaunchOption[] = [ + { agent: 'gemini', aliases: ['gemini'], label: 'Gemini' } + ] + const onLaunchAgent = vi.fn() + mount( + + ) + + // A partial query surfaces the agent (issue #1); it is the top row, so Enter + // launches it. + setQuery('gem') + submitForm() + + expect(onLaunchAgent).toHaveBeenCalledWith('gemini') + }) + + it('exposes the highlighted row to assistive tech via aria-activedescendant', () => { + entryOptionsMock.options = [fileOption('a.ts'), fileOption('b.ts'), fileOption('c.ts')] + mount( + + ) + + const input = container.querySelector('input')! + expect(input.getAttribute('role')).toBe('combobox') + expect(input.getAttribute('aria-activedescendant')).toBe('tab-create-entry-result-0') + + pressKey(input, 'ArrowDown') + expect(input.getAttribute('aria-activedescendant')).toBe('tab-create-entry-result-1') + const active = document.getElementById('tab-create-entry-result-1') + expect(active?.getAttribute('role')).toBe('option') + expect(active?.getAttribute('aria-selected')).toBe('true') + }) + + it('routes ArrowDown into the enclosing menu when there are no result rows', () => { + mount( +
+ + + +
+ ) + + const firstItem = container.querySelector('[role="menuitem"]') as HTMLButtonElement + pressKey(container.querySelector('input')!, 'ArrowDown') + + expect(document.activeElement).toBe(firstItem) + }) + + it('returns focus to the input on ArrowUp from the first menu item (no dead-end)', () => { + mount( +
+ + + +
+ ) + + const input = container.querySelector('input')! + const firstItem = container.querySelector('[role="menuitem"]') as HTMLButtonElement + act(() => firstItem.focus()) + expect(document.activeElement).toBe(firstItem) + + pressKey(firstItem, 'ArrowUp') + expect(document.activeElement).toBe(input) + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index f32a94ff1c6..619e906df18 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -1,15 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' -import { FilePlus, FileText, Globe, Loader2, Smartphone, TerminalSquare } from 'lucide-react' import { Input } from '@/components/ui/input' -import { AgentIcon } from '@/lib/agent-catalog' -import { cn } from '@/lib/utils' import { useRuntimeFileListForWorktree } from '../quick-open-file-list' -import { - getTabEntryOptions, - type TabCreateEntryArgs, - type TabEntryActionClassification, - type TabEntryOption -} from './tab-create-entry-action' +import { getTabEntryOptions, type TabCreateEntryArgs } from './tab-create-entry-action' import { findMatchingTabAgentLaunchOptions, type TabAgentLaunchOption @@ -18,6 +10,17 @@ import { findMatchingTabCreateMenuOptions, type TabCreateMenuOption } from './tab-create-menu-options' +import { + getActiveOptionId, + isActiveEntryOption, + type ActiveOption +} from './tab-create-entry-active-option' +import { + EntryActionRow, + EntryStatusRow, + RESULT_LISTBOX_ID, + resultOptionDomId +} from './TabBarCreateEntryRow' import type { TuiAgent } from '../../../../shared/types' import { translate } from '@/i18n/i18n' @@ -60,6 +63,35 @@ export default function TabBarCreateEntry({ const inputRef = useRef(null) const fileList = useRuntimeFileListForWorktree({ enabled: menuOpen, worktreeId }) + // Why: once ArrowDown moves focus into the static menu list, ArrowUp on the + // first item should return to the search box so the keyboard trip isn't + // one-way. Capture phase beats Radix's roving-focus handler. + useEffect(() => { + if (!menuOpen) { + return + } + const input = inputRef.current + const menu = input?.closest('[role="menu"]') + if (!input || !menu) { + return + } + const handleMenuKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'ArrowUp') { + return + } + const firstItem = menu.querySelector( + '[role="menuitem"]:not([data-disabled]):not([aria-disabled="true"])' + ) + if (firstItem && document.activeElement === firstItem) { + event.preventDefault() + event.stopPropagation() + input.focus() + } + } + menu.addEventListener('keydown', handleMenuKeyDown, true) + return () => menu.removeEventListener('keydown', handleMenuKeyDown, true) + }, [menuOpen]) + useEffect(() => { if (!menuOpen) { return @@ -185,14 +217,26 @@ export default function TabBarCreateEntry({ submitOption() }} onKeyDown={(event) => { - if (activeOptions.length > 1 && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { - event.preventDefault() - event.stopPropagation() - setSelectedIndex((current) => { - const delta = event.key === 'ArrowDown' ? 1 : -1 - return (current + delta + activeOptions.length) % activeOptions.length - }) - return + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (activeOptions.length > 0) { + event.preventDefault() + event.stopPropagation() + setSelectedIndex((current) => { + const delta = event.key === 'ArrowDown' ? 1 : -1 + return (current + delta + activeOptions.length) % activeOptions.length + }) + return + } + // Why: with no result rows the static create/agent items render below; + // move focus into that Radix menu list so it stays keyboard-navigable + // from the search box instead of trapping focus in the input. + if ( + focusMenuItemAtEdge(event.currentTarget, event.key === 'ArrowDown' ? 'first' : 'last') + ) { + event.preventDefault() + event.stopPropagation() + return + } } if (event.key !== 'Escape') { event.stopPropagation() @@ -209,6 +253,13 @@ export default function TabBarCreateEntry({ setError(null) }} disabled={disabled} + role="combobox" + aria-expanded={activeOptions.length > 0} + aria-controls={RESULT_LISTBOX_ID} + aria-autocomplete="list" + aria-activedescendant={ + activeOptions.length > 0 && !error ? resultOptionDomId(activeSelectedIndex) : undefined + } aria-label={translate( 'auto.components.tab.bar.TabBarCreateEntry.39676a184c', 'Open any file, URL, agent, ...' @@ -222,13 +273,18 @@ export default function TabBarCreateEntry({ />
{error || activeOptions.length > 0 || hasQuery ? ( -
+
0 && !error ? 'listbox' : undefined} + > {error ? ( ) : activeOptions.length > 0 ? ( activeOptions.map((option, index) => ( submitOption(option)} @@ -243,146 +299,20 @@ export default function TabBarCreateEntry({ ) } -type ActiveEntryOption = TabEntryOption & { - classification: TabEntryActionClassification -} - -type ActiveOption = - | { - kind: 'agent' - option: TabAgentLaunchOption - } - | { - kind: 'entry' - option: ActiveEntryOption - } - | { - kind: 'menu' - option: TabCreateMenuOption - } - -function isActiveEntryOption(option: TabEntryOption): option is ActiveEntryOption { - return option.classification.kind !== 'empty' && option.classification.kind !== 'blocked' -} - -function getActiveOptionId(option: ActiveOption): string { - if (option.kind === 'agent') { - return `agent:${option.option.agent}` +// Moves keyboard focus to the first/last enabled item of the enclosing Radix +// menu so the static create/agent list stays navigable from the search input. +function focusMenuItemAtEdge(fromElement: HTMLElement, edge: 'first' | 'last'): boolean { + const menu = fromElement.closest('[role="menu"]') + if (!menu) { + return false } - if (option.kind === 'menu') { - return `menu:${option.option.id}` - } - return option.option.id -} - -function EntryStatusRow({ - loading = false, - message -}: { - loading?: boolean - message: string -}): React.JSX.Element { - return ( -
- {loading ?
+ const items = menu.querySelectorAll( + '[role="menuitem"]:not([data-disabled]):not([aria-disabled="true"])' ) -} - -function EntryActionRow({ - onClick, - option, - selected -}: { - onClick: () => void - option: ActiveOption - selected: boolean -}): React.JSX.Element { - const presentation = getActionPresentation(option) - - return ( - - ) -} - -function getActionPresentation(option: ActiveOption): { - detail: string - icon: React.ReactNode - label: string - showDetail: boolean -} { - if (option.kind === 'menu') { - const icon = - option.option.kind === 'new-browser' ? ( -