From 22266895fa61969e6dcc7531d8d958b4009a11ef Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 14:37:14 -0700 Subject: [PATCH 001/224] Document rich markdown menu selection perf PR (#3327) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index 89109c23887..d8918006520 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,7 +49,7 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, and #3300, plus upstream tab-entry changes #3011 and #3026: 912 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, and #3322 each project to 911 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 910; open medium-risk PRs #3114, #3168, and #3259 each project to 909; open medium-risk PRs #3095, #3140, and #3230 each project to 908; open medium-risk PR #3079 projects to 902; open high-risk PRs #3157, #3212, and #3253 each project to 911; open high-risk PR #3267 projects to 910; open high-risk PR #3075 projects to 908; open high-risk PR #3250 projects to 907; and open high-risk PR #3081 projects to 904. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, and #3322 each project to 911 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 910; open medium-risk PRs #3114, #3168, and #3259 each project to 909; open medium-risk PRs #3095, #3140, and #3230 each project to 908; open medium-risk PR #3326 projects to 903; open medium-risk PR #3079 projects to 902; open high-risk PRs #3157, #3212, and #3253 each project to 911; open high-risk PR #3267 projects to 910; open high-risk PR #3075 projects to 908; open high-risk PR #3250 projects to 907; and open high-risk PR #3081 projects to 904. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -57,7 +57,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Terminal / PTY | `components/Terminal.tsx`, `components/terminal-pane/**`, `components/terminal/**` | Inventory complete, manual review pending | Mobile driver overlay collapse repair covered by #3170; floating workspace first-open bounds normalization covered by #3269; unused legacy terminal shell/shortcut/hook files deleted by #3271; terminal tab rename focus covered by #3314 without PTY protocol changes. High-risk area: xterm lifecycle, scrollback, remote/mobile parity, focus, WebGL, resize. | | Renderer mobile page | `components/mobile/**` | Inventory complete, manual review in progress | Mobile pairing page stage repair covered by #3180. Continue checking QR generation, mobile-page IPC, and paired-device polling. | | Browser pane | `components/browser-pane/**` | Inventory complete, manual review in progress | Highest Effect density: 62 sites in `BrowserPane.tsx`. Browser ref mirrors covered by #3081; BrowserFind debounced-query state covered by #3275. Continue with driver sync, address bar derived state, remaining find state, webview lifetime. | -| Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | +| Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | | Sidebar / worktrees | `components/sidebar/**` | Inventory complete, manual review in progress | Orca hook trust checkbox reset covered by #3089; delete dialog one-shot checkbox reset covered by #3134; Add Repo clone destination auto-fill covered by #3150; sidebar live-width mirror covered by #3160; worktree selection pruning covered by #3166; SSH reconnect prompts covered by #3267; nested repo indeterminate checkbox covered by #3294; workspace title rename focus covered by #3296; workspace title editing notification covered by #3298; worktree metadata notes resize covered by #3300. Continue with drag/drop global listeners and kanban pointer flows. | | Workspace cleanup | `components/workspace-cleanup/**` | Inventory complete, manual review in progress | Active cleanup-view repair covered by #3195. Continue checking destructive removal, repo filtering, scan refresh, and SSH/disconnected blockers. | | Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | @@ -207,6 +207,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR DX | Rich markdown link bubble focus | The link-edit bubble focused and selected the URL input from a passive Effect after edit mode mounted. | `RichMarkdownLinkBubble.tsx` covered by #3316 | Medium | | PR DY | Image viewer preview source | Image previews built a blob URL and reset stale load-error state from a passive Effect after the editor rendered. | `ImageViewer.tsx` covered by #3318 | Medium | | PR DZ | Markdown preview search focus | Markdown preview search focused and selected the find input from a passive Effect after search mode rendered. | `MarkdownPreview.tsx` covered by #3322 | Medium | +| PR EA | Rich markdown menu selection | Slash-menu and doc-link-menu selected indexes were reset, clamped, and mirrored into key-handler refs from passive Effects after menu/query changes. | `RichMarkdownEditor.tsx` covered by #3326 | Medium | ## Merge Risk Scale @@ -342,6 +343,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3316 | `nwparker/react-perf-link-bubble-focus-ref` | Rich markdown link bubble input focus moves to callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/rich-markdown-key-handler.test.ts src/renderer/src/components/editor/markdown-internal-links.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | | #3318 | `nwparker/react-perf-image-viewer-preview-src` | Image viewer preview source derives during render | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/ImageViewer.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/ImageViewer.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/markdown-internal-links.test.ts src/renderer/src/components/editor/markdown-preview-links.test.ts src/renderer/src/components/editor/markdown-preview-url-transform.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | | #3322 | `nwparker/react-perf-markdown-preview-search-focus-ref` | Markdown preview search input focus moves to callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/markdown-internal-links.test.ts src/renderer/src/components/editor/markdown-preview-links.test.ts src/renderer/src/components/editor/markdown-preview-url-transform.test.ts src/renderer/src/components/editor/markdown-doc-links.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | +| #3326 | `nwparker/react-perf-rich-markdown-menu-selection-render` | Rich markdown slash/doc-link menu selection derives during render | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/RichMarkdownEditor.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/rich-markdown-key-handler.test.ts src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx src/renderer/src/components/editor/markdown-doc-completions.test.ts src/renderer/src/components/editor/rich-markdown-commands.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 903. | ## Reproduction Commands From 6cfdb77b56d74d6a0b3964d3e9b2b69a59bc0867 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 14:44:10 -0700 Subject: [PATCH 002/224] Document editor header rename focus perf PR (#3329) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index d8918006520..9a70d73b4a6 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,7 +49,7 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, and #3300, plus upstream tab-entry changes #3011 and #3026: 912 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, and #3322 each project to 911 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 910; open medium-risk PRs #3114, #3168, and #3259 each project to 909; open medium-risk PRs #3095, #3140, and #3230 each project to 908; open medium-risk PR #3326 projects to 903; open medium-risk PR #3079 projects to 902; open high-risk PRs #3157, #3212, and #3253 each project to 911; open high-risk PR #3267 projects to 910; open high-risk PR #3075 projects to 908; open high-risk PR #3250 projects to 907; and open high-risk PR #3081 projects to 904. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, and #3328 each project to 911 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 910; open medium-risk PRs #3114, #3168, and #3259 each project to 909; open medium-risk PRs #3095, #3140, and #3230 each project to 908; open medium-risk PR #3326 projects to 903; open medium-risk PR #3079 projects to 902; open high-risk PRs #3157, #3212, and #3253 each project to 911; open high-risk PR #3267 projects to 910; open high-risk PR #3075 projects to 908; open high-risk PR #3250 projects to 907; and open high-risk PR #3081 projects to 904. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -57,7 +57,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Terminal / PTY | `components/Terminal.tsx`, `components/terminal-pane/**`, `components/terminal/**` | Inventory complete, manual review pending | Mobile driver overlay collapse repair covered by #3170; floating workspace first-open bounds normalization covered by #3269; unused legacy terminal shell/shortcut/hook files deleted by #3271; terminal tab rename focus covered by #3314 without PTY protocol changes. High-risk area: xterm lifecycle, scrollback, remote/mobile parity, focus, WebGL, resize. | | Renderer mobile page | `components/mobile/**` | Inventory complete, manual review in progress | Mobile pairing page stage repair covered by #3180. Continue checking QR generation, mobile-page IPC, and paired-device polling. | | Browser pane | `components/browser-pane/**` | Inventory complete, manual review in progress | Highest Effect density: 62 sites in `BrowserPane.tsx`. Browser ref mirrors covered by #3081; BrowserFind debounced-query state covered by #3275. Continue with driver sync, address bar derived state, remaining find state, webview lifetime. | -| Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | +| Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | | Sidebar / worktrees | `components/sidebar/**` | Inventory complete, manual review in progress | Orca hook trust checkbox reset covered by #3089; delete dialog one-shot checkbox reset covered by #3134; Add Repo clone destination auto-fill covered by #3150; sidebar live-width mirror covered by #3160; worktree selection pruning covered by #3166; SSH reconnect prompts covered by #3267; nested repo indeterminate checkbox covered by #3294; workspace title rename focus covered by #3296; workspace title editing notification covered by #3298; worktree metadata notes resize covered by #3300. Continue with drag/drop global listeners and kanban pointer flows. | | Workspace cleanup | `components/workspace-cleanup/**` | Inventory complete, manual review in progress | Active cleanup-view repair covered by #3195. Continue checking destructive removal, repo filtering, scan refresh, and SSH/disconnected blockers. | | Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | @@ -208,6 +208,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR DY | Image viewer preview source | Image previews built a blob URL and reset stale load-error state from a passive Effect after the editor rendered. | `ImageViewer.tsx` covered by #3318 | Medium | | PR DZ | Markdown preview search focus | Markdown preview search focused and selected the find input from a passive Effect after search mode rendered. | `MarkdownPreview.tsx` covered by #3322 | Medium | | PR EA | Rich markdown menu selection | Slash-menu and doc-link-menu selected indexes were reset, clamped, and mirrored into key-handler refs from passive Effects after menu/query changes. | `RichMarkdownEditor.tsx` covered by #3326 | Medium | +| PR EB | Editor header rename focus | Header inline-file rename focused and selected the filename input from a passive Effect after rename mode rendered. | `editor-header-file-rename.ts` covered by #3328 | Medium | ## Merge Risk Scale @@ -344,6 +345,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3318 | `nwparker/react-perf-image-viewer-preview-src` | Image viewer preview source derives during render | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/ImageViewer.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/ImageViewer.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/markdown-internal-links.test.ts src/renderer/src/components/editor/markdown-preview-links.test.ts src/renderer/src/components/editor/markdown-preview-url-transform.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | | #3322 | `nwparker/react-perf-markdown-preview-search-focus-ref` | Markdown preview search input focus moves to callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/markdown-internal-links.test.ts src/renderer/src/components/editor/markdown-preview-links.test.ts src/renderer/src/components/editor/markdown-preview-url-transform.test.ts src/renderer/src/components/editor/markdown-doc-links.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | | #3326 | `nwparker/react-perf-rich-markdown-menu-selection-render` | Rich markdown slash/doc-link menu selection derives during render | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/RichMarkdownEditor.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/rich-markdown-key-handler.test.ts src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx src/renderer/src/components/editor/markdown-doc-completions.test.ts src/renderer/src/components/editor/rich-markdown-commands.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 903. | +| #3328 | `nwparker/react-perf-editor-header-rename-focus-ref` | Editor header inline rename input focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/editor-header-file-rename.ts src/renderer/src/components/editor/EditorPanelHeader.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | ## Reproduction Commands From 351ad38f684a3742deebbeaf74809a5f81ee9b7a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 14:50:05 -0700 Subject: [PATCH 003/224] Focus file search input from ref (#3330) --- src/renderer/src/components/right-sidebar/Search.tsx | 12 ++++++++---- .../src/components/right-sidebar/SearchHeader.tsx | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/Search.tsx b/src/renderer/src/components/right-sidebar/Search.tsx index c62907d6741..82934cfc46c 100644 --- a/src/renderer/src/components/right-sidebar/Search.tsx +++ b/src/renderer/src/components/right-sidebar/Search.tsx @@ -106,9 +106,13 @@ export default function Search(): React.JSX.Element { }) }, [cancelSeededInputSelectionFrame]) - // Focus input on mount - useEffect(() => { - inputRef.current?.focus() + const setSearchInputRef = useCallback((el: HTMLInputElement | null): void => { + inputRef.current = el + // Why: focusing belongs to the input mount; the object ref still backs + // seeded-search selection and result keyboard handlers. + if (el) { + el.focus() + } }, []) // Cleanup debounce timer on unmount @@ -324,7 +328,7 @@ export default function Search(): React.JSX.Element { return (
+ inputRef: React.Ref includeInputRef: React.RefObject excludeInputRef: React.RefObject query: string From 5a55d5828e1ad74d5460e806185f99814d6fafcc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 14:53:21 -0700 Subject: [PATCH 004/224] Document file search focus perf fix (#3331) --- docs/reference/react-performance-audit.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index 9a70d73b4a6..5e193d8993d 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -47,9 +47,9 @@ Initial inventory: ## Coverage Ledger -Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, and #3300, plus upstream tab-entry changes #3011 and #3026: 912 Effect hook call sites. +Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026: 911 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, and #3328 each project to 911 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 910; open medium-risk PRs #3114, #3168, and #3259 each project to 909; open medium-risk PRs #3095, #3140, and #3230 each project to 908; open medium-risk PR #3326 projects to 903; open medium-risk PR #3079 projects to 902; open high-risk PRs #3157, #3212, and #3253 each project to 911; open high-risk PR #3267 projects to 910; open high-risk PR #3075 projects to 908; open high-risk PR #3250 projects to 907; and open high-risk PR #3081 projects to 904. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, and #3328 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, and #3253 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -60,7 +60,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | | Sidebar / worktrees | `components/sidebar/**` | Inventory complete, manual review in progress | Orca hook trust checkbox reset covered by #3089; delete dialog one-shot checkbox reset covered by #3134; Add Repo clone destination auto-fill covered by #3150; sidebar live-width mirror covered by #3160; worktree selection pruning covered by #3166; SSH reconnect prompts covered by #3267; nested repo indeterminate checkbox covered by #3294; workspace title rename focus covered by #3296; workspace title editing notification covered by #3298; worktree metadata notes resize covered by #3300. Continue with drag/drop global listeners and kanban pointer flows. | | Workspace cleanup | `components/workspace-cleanup/**` | Inventory complete, manual review in progress | Active cleanup-view repair covered by #3195. Continue checking destructive removal, repo filtering, scan refresh, and SSH/disconnected blockers. | -| Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | +| Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212; file-search input focus covered by #3330. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | | Settings | `components/settings/**` | Inventory complete, manual review in progress | Repository hook local editor visibility covered by #3093; lazy section mounted-state bookkeeping covered by #3128; browser home/Kagi draft sync covered by #3136; general auto-save/Open In draft sync covered by #3138; Source Control AI instruction drafts covered by #3140; notification volume draft sync covered by #3142; repo Source Control AI override draft sync covered by #3144; Quick Commands add intent covered by #3146; Mobile QR polling count capture covered by #3152; integration preflight status derivation covered by #3154; SSH reset-relay dialog cleanup covered by #3157; diagnostics bundle cleanup ref mirror covered by #3176. Continue with draft mirrors while keeping SSH and cross-platform settings behavior intact. | | Automations | `components/automations/**` | Inventory complete, manual review in progress | Combobox open-focus Effects covered by #3100; run-history selection repair covered by #3104; external run-table selection repair covered by #3102; detail-page selection resets covered by #3106; create-from branch search reset covered by #3132. Continue with draft defaulting, polling, and completion detection. | | Issue, PR, task pages | `TaskPage.tsx`, `PullRequestPage.tsx`, `GitHubItemDialog.tsx`, `GitLabItemDialog.tsx`, Linear components | Inventory complete, manual review in progress | Linear estimate input sync covered by #3108; Linear sub-issue list sync covered by #3110; GitHub project date draft sync covered by #3112; GitHub reviewer-picker active index covered by #3114; GitHub project column state covered by #3116; GitHub Project visible-table cache covered by #3186; GitHub Project dialog state covered by #3214; GitLab comment draft reset covered by #3124; Linear title/description draft sync covered by #3130; GitHub assignee dirty-flag reset covered by #3255; GitHub reviewer-request local sync covered by #3259; GitHub link-copy feedback timers covered by #3285; GitLab task filter repair covered by #3292. Continue large-file scan with separate GitHub, GitLab, Linear, and generic review behavior. | @@ -209,6 +209,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR DZ | Markdown preview search focus | Markdown preview search focused and selected the find input from a passive Effect after search mode rendered. | `MarkdownPreview.tsx` covered by #3322 | Medium | | PR EA | Rich markdown menu selection | Slash-menu and doc-link-menu selected indexes were reset, clamped, and mirrored into key-handler refs from passive Effects after menu/query changes. | `RichMarkdownEditor.tsx` covered by #3326 | Medium | | PR EB | Editor header rename focus | Header inline-file rename focused and selected the filename input from a passive Effect after rename mode rendered. | `editor-header-file-rename.ts` covered by #3328 | Medium | +| PR EC | File search input focus | The right-sidebar file-search input focused itself from a passive Effect after the Search panel mounted. | `Search.tsx`, `SearchHeader.tsx` covered by #3330 | Low | ## Merge Risk Scale @@ -346,6 +347,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3322 | `nwparker/react-perf-markdown-preview-search-focus-ref` | Markdown preview search input focus moves to callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/markdown-internal-links.test.ts src/renderer/src/components/editor/markdown-preview-links.test.ts src/renderer/src/components/editor/markdown-preview-url-transform.test.ts src/renderer/src/components/editor/markdown-doc-links.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | | #3326 | `nwparker/react-perf-rich-markdown-menu-selection-render` | Rich markdown slash/doc-link menu selection derives during render | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/RichMarkdownEditor.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/rich-markdown-key-handler.test.ts src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx src/renderer/src/components/editor/markdown-doc-completions.test.ts src/renderer/src/components/editor/rich-markdown-commands.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 903. | | #3328 | `nwparker/react-perf-editor-header-rename-focus-ref` | Editor header inline rename input focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/editor-header-file-rename.ts src/renderer/src/components/editor/EditorPanelHeader.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | +| #3330 | `nwparker/react-perf-file-search-focus-ref` | File search input focus moves to callback ref | Low | Merged | `pnpm exec oxlint src/renderer/src/components/right-sidebar/Search.tsx src/renderer/src/components/right-sidebar/SearchHeader.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/editor.test.ts src/renderer/src/lib/file-search-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | ## Reproduction Commands From 5bb8b106a099a7f37e99d293fe2b1416d894743f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 14:58:17 -0700 Subject: [PATCH 005/224] Document diff comment popover focus perf PR (#3336) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index 5e193d8993d..8a72cbfbd68 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,7 +49,7 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026: 911 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, and #3328 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, and #3253 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, and #3333 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, and #3253 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -57,7 +57,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Terminal / PTY | `components/Terminal.tsx`, `components/terminal-pane/**`, `components/terminal/**` | Inventory complete, manual review pending | Mobile driver overlay collapse repair covered by #3170; floating workspace first-open bounds normalization covered by #3269; unused legacy terminal shell/shortcut/hook files deleted by #3271; terminal tab rename focus covered by #3314 without PTY protocol changes. High-risk area: xterm lifecycle, scrollback, remote/mobile parity, focus, WebGL, resize. | | Renderer mobile page | `components/mobile/**` | Inventory complete, manual review in progress | Mobile pairing page stage repair covered by #3180. Continue checking QR generation, mobile-page IPC, and paired-device polling. | | Browser pane | `components/browser-pane/**` | Inventory complete, manual review in progress | Highest Effect density: 62 sites in `BrowserPane.tsx`. Browser ref mirrors covered by #3081; BrowserFind debounced-query state covered by #3275. Continue with driver sync, address bar derived state, remaining find state, webview lifetime. | -| Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | +| Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328; diff-comment popover focus covered by #3333. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | | Sidebar / worktrees | `components/sidebar/**` | Inventory complete, manual review in progress | Orca hook trust checkbox reset covered by #3089; delete dialog one-shot checkbox reset covered by #3134; Add Repo clone destination auto-fill covered by #3150; sidebar live-width mirror covered by #3160; worktree selection pruning covered by #3166; SSH reconnect prompts covered by #3267; nested repo indeterminate checkbox covered by #3294; workspace title rename focus covered by #3296; workspace title editing notification covered by #3298; worktree metadata notes resize covered by #3300. Continue with drag/drop global listeners and kanban pointer flows. | | Workspace cleanup | `components/workspace-cleanup/**` | Inventory complete, manual review in progress | Active cleanup-view repair covered by #3195. Continue checking destructive removal, repo filtering, scan refresh, and SSH/disconnected blockers. | | Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212; file-search input focus covered by #3330. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | @@ -210,6 +210,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR EA | Rich markdown menu selection | Slash-menu and doc-link-menu selected indexes were reset, clamped, and mirrored into key-handler refs from passive Effects after menu/query changes. | `RichMarkdownEditor.tsx` covered by #3326 | Medium | | PR EB | Editor header rename focus | Header inline-file rename focused and selected the filename input from a passive Effect after rename mode rendered. | `editor-header-file-rename.ts` covered by #3328 | Medium | | PR EC | File search input focus | The right-sidebar file-search input focused itself from a passive Effect after the Search panel mounted. | `Search.tsx`, `SearchHeader.tsx` covered by #3330 | Low | +| PR ED | Diff comment popover focus | The diff-comment popover textarea focused itself from a passive Effect after the note popover mounted. | `DiffCommentPopover.tsx` covered by #3333 | Medium | ## Merge Risk Scale @@ -348,6 +349,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3326 | `nwparker/react-perf-rich-markdown-menu-selection-render` | Rich markdown slash/doc-link menu selection derives during render | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/RichMarkdownEditor.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/rich-markdown-key-handler.test.ts src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx src/renderer/src/components/editor/markdown-doc-completions.test.ts src/renderer/src/components/editor/rich-markdown-commands.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 903. | | #3328 | `nwparker/react-perf-editor-header-rename-focus-ref` | Editor header inline rename input focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/editor-header-file-rename.ts src/renderer/src/components/editor/EditorPanelHeader.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | | #3330 | `nwparker/react-perf-file-search-focus-ref` | File search input focus moves to callback ref | Low | Merged | `pnpm exec oxlint src/renderer/src/components/right-sidebar/Search.tsx src/renderer/src/components/right-sidebar/SearchHeader.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/editor.test.ts src/renderer/src/lib/file-search-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | +| #3333 | `nwparker/react-perf-diff-comment-popover-focus-ref` | Diff-comment popover textarea focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/diff-comments/DiffCommentPopover.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | ## Reproduction Commands From 5dfc0454ed91e95e1c5eb13342f17ea8ffd18433 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 15:04:14 -0700 Subject: [PATCH 006/224] Document markdown annotation focus perf PR (#3339) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index 8a72cbfbd68..f85e3c4db71 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,7 +49,7 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026: 911 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, and #3333 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, and #3253 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, and #3338 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, and #3253 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -57,7 +57,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Terminal / PTY | `components/Terminal.tsx`, `components/terminal-pane/**`, `components/terminal/**` | Inventory complete, manual review pending | Mobile driver overlay collapse repair covered by #3170; floating workspace first-open bounds normalization covered by #3269; unused legacy terminal shell/shortcut/hook files deleted by #3271; terminal tab rename focus covered by #3314 without PTY protocol changes. High-risk area: xterm lifecycle, scrollback, remote/mobile parity, focus, WebGL, resize. | | Renderer mobile page | `components/mobile/**` | Inventory complete, manual review in progress | Mobile pairing page stage repair covered by #3180. Continue checking QR generation, mobile-page IPC, and paired-device polling. | | Browser pane | `components/browser-pane/**` | Inventory complete, manual review in progress | Highest Effect density: 62 sites in `BrowserPane.tsx`. Browser ref mirrors covered by #3081; BrowserFind debounced-query state covered by #3275. Continue with driver sync, address bar derived state, remaining find state, webview lifetime. | -| Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328; diff-comment popover focus covered by #3333. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | +| Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328; diff-comment popover focus covered by #3333; markdown annotation composer focus covered by #3338. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | | Sidebar / worktrees | `components/sidebar/**` | Inventory complete, manual review in progress | Orca hook trust checkbox reset covered by #3089; delete dialog one-shot checkbox reset covered by #3134; Add Repo clone destination auto-fill covered by #3150; sidebar live-width mirror covered by #3160; worktree selection pruning covered by #3166; SSH reconnect prompts covered by #3267; nested repo indeterminate checkbox covered by #3294; workspace title rename focus covered by #3296; workspace title editing notification covered by #3298; worktree metadata notes resize covered by #3300. Continue with drag/drop global listeners and kanban pointer flows. | | Workspace cleanup | `components/workspace-cleanup/**` | Inventory complete, manual review in progress | Active cleanup-view repair covered by #3195. Continue checking destructive removal, repo filtering, scan refresh, and SSH/disconnected blockers. | | Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212; file-search input focus covered by #3330. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | @@ -211,6 +211,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR EB | Editor header rename focus | Header inline-file rename focused and selected the filename input from a passive Effect after rename mode rendered. | `editor-header-file-rename.ts` covered by #3328 | Medium | | PR EC | File search input focus | The right-sidebar file-search input focused itself from a passive Effect after the Search panel mounted. | `Search.tsx`, `SearchHeader.tsx` covered by #3330 | Low | | PR ED | Diff comment popover focus | The diff-comment popover textarea focused itself from a passive Effect after the note popover mounted. | `DiffCommentPopover.tsx` covered by #3333 | Medium | +| PR EE | Markdown annotation composer focus | The markdown-preview annotation composer focused its note textarea from a passive Effect after the composer mounted. | `MarkdownPreview.tsx` covered by #3338 | Medium | ## Merge Risk Scale @@ -350,6 +351,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3328 | `nwparker/react-perf-editor-header-rename-focus-ref` | Editor header inline rename input focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/editor-header-file-rename.ts src/renderer/src/components/editor/EditorPanelHeader.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | | #3330 | `nwparker/react-perf-file-search-focus-ref` | File search input focus moves to callback ref | Low | Merged | `pnpm exec oxlint src/renderer/src/components/right-sidebar/Search.tsx src/renderer/src/components/right-sidebar/SearchHeader.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/editor.test.ts src/renderer/src/lib/file-search-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | | #3333 | `nwparker/react-perf-diff-comment-popover-focus-ref` | Diff-comment popover textarea focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/diff-comments/DiffCommentPopover.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | +| #3338 | `nwparker/react-perf-markdown-annotation-focus-ref` | Markdown annotation composer textarea focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/MarkdownPreview.test.ts src/renderer/src/components/editor/markdown-preview-links.test.ts src/renderer/src/components/editor/markdown-preview-search.test.ts src/renderer/src/components/editor/markdown-preview-controls.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | ## Reproduction Commands From 7751af26c49a89ded6f74b2156b15560c31c43d7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 15:11:01 -0700 Subject: [PATCH 007/224] Document status bar account usage perf PR (#3342) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index f85e3c4db71..c9a2a1dba48 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,7 +49,7 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026: 911 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, and #3338 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, and #3253 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, and #3340 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, and #3253 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -65,7 +65,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Automations | `components/automations/**` | Inventory complete, manual review in progress | Combobox open-focus Effects covered by #3100; run-history selection repair covered by #3104; external run-table selection repair covered by #3102; detail-page selection resets covered by #3106; create-from branch search reset covered by #3132. Continue with draft defaulting, polling, and completion detection. | | Issue, PR, task pages | `TaskPage.tsx`, `PullRequestPage.tsx`, `GitHubItemDialog.tsx`, `GitLabItemDialog.tsx`, Linear components | Inventory complete, manual review in progress | Linear estimate input sync covered by #3108; Linear sub-issue list sync covered by #3110; GitHub project date draft sync covered by #3112; GitHub reviewer-picker active index covered by #3114; GitHub project column state covered by #3116; GitHub Project visible-table cache covered by #3186; GitHub Project dialog state covered by #3214; GitLab comment draft reset covered by #3124; Linear title/description draft sync covered by #3130; GitHub assignee dirty-flag reset covered by #3255; GitHub reviewer-request local sync covered by #3259; GitHub link-copy feedback timers covered by #3285; GitLab task filter repair covered by #3292. Continue large-file scan with separate GitHub, GitLab, Linear, and generic review behavior. | | Onboarding / feature wall | `components/onboarding/**`, `components/feature-wall/**` | Inventory complete, manual review in progress | Select portal root Effects covered by #3087; tour visited-marker Effects covered by #3095; persisted completion close reset covered by #3188; onboarding settings hydration covered by #3198; unused orchestration notifications page deleted by #3273; feature-wall skill installed-state notification Effects covered by #3281; session-depth open publish Effects covered by #3283; shared reduced-motion media-query hook covered by #3287. Continue with demo timers and telemetry while avoiding telemetry semantic changes. | -| Status, dashboard, activity | `components/status-bar/**`, `components/dashboard/**`, `components/activity/**`, `components/pet/**` | Inventory complete, manual review in progress | Status-bar account menu close reset covered by #3051; pet overlay size clamp covered by #3118; Activity selected-thread repair covered by #3126; Workspace Space row-state repairs covered by #3168; Resource Manager Space scan-ready latch covered by #3200; pet reduced-motion subscription covered by #3287. Check interval sharing, retained agent state, activity terminal portals. | +| Status, dashboard, activity | `components/status-bar/**`, `components/dashboard/**`, `components/activity/**`, `components/pet/**` | Inventory complete, manual review in progress | Status-bar account menu close reset covered by #3051; pet overlay size clamp covered by #3118; Activity selected-thread repair covered by #3126; Workspace Space row-state repairs covered by #3168; Resource Manager Space scan-ready latch covered by #3200; pet reduced-motion subscription covered by #3287; status-bar account usage fetch covered by #3340. Check interval sharing, retained agent state, activity terminal portals. | | Mobile app routes | `mobile/app/**` | Inventory complete, manual review in progress | Pair-confirm route-code parsing covered by #3240; host home new-worktree route action covered by #3242; Accounts unused client ref covered by #3244; mobile home shared-client ref mirror covered by #3247; session route terminal/tab ref mirrors covered by #3250; tasks route client ref mirror covered by #3253; tasks route workspace agent selection covered by #3261. Continue with large `tasks.tsx` state/polling Effects, session route cleanup Effects, home route ref mirrors, and remote-client parity. | | Mobile shared source | `mobile/src/**` | Inventory complete, manual review in progress | TextInputModal and CustomKeyModal open-state resets covered by #3223; BottomDrawer mount bookkeeping covered by #3226; MobileBrowserPane address sync covered by #3228; MobileBrowserPane ref mirrors covered by #3230; useMobileDictation ref mirrors covered by #3234; NewWorktreeModal agent selection covered by #3238. Continue with transport client context plus NewWorktreeModal open/reset, SSH, agent-detection, and setup-hook fetch Effects. | | Expo two-way audio hook | `mobile/packages/expo-two-way-audio/src/hooks.ts` | Inventory complete, manual review pending | Single Effect plus `useSyncExternalStore`; verify native subscription cleanup. | @@ -212,6 +212,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR EC | File search input focus | The right-sidebar file-search input focused itself from a passive Effect after the Search panel mounted. | `Search.tsx`, `SearchHeader.tsx` covered by #3330 | Low | | PR ED | Diff comment popover focus | The diff-comment popover textarea focused itself from a passive Effect after the note popover mounted. | `DiffCommentPopover.tsx` covered by #3333 | Medium | | PR EE | Markdown annotation composer focus | The markdown-preview annotation composer focused its note textarea from a passive Effect after the composer mounted. | `MarkdownPreview.tsx` covered by #3338 | Medium | +| PR EF | Status-bar inactive account usage fetch | Expanding the Claude account switcher kicked off inactive-account usage loading from a passive Effect after the submenu rendered. | `StatusBar.tsx` covered by #3340 | Medium | ## Merge Risk Scale @@ -352,6 +353,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3330 | `nwparker/react-perf-file-search-focus-ref` | File search input focus moves to callback ref | Low | Merged | `pnpm exec oxlint src/renderer/src/components/right-sidebar/Search.tsx src/renderer/src/components/right-sidebar/SearchHeader.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/editor.test.ts src/renderer/src/lib/file-search-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | | #3333 | `nwparker/react-perf-diff-comment-popover-focus-ref` | Diff-comment popover textarea focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/diff-comments/DiffCommentPopover.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3338 | `nwparker/react-perf-markdown-annotation-focus-ref` | Markdown annotation composer textarea focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/MarkdownPreview.test.ts src/renderer/src/components/editor/markdown-preview-links.test.ts src/renderer/src/components/editor/markdown-preview-search.test.ts src/renderer/src/components/editor/markdown-preview-controls.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | +| #3340 | `nwparker/react-perf-status-bar-account-usage-fetch` | Status-bar inactive Claude account usage fetch moves to the expand handler | Medium | Open | `pnpm exec oxlint src/renderer/src/components/status-bar/StatusBar.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/status-bar/status-bar-context-menu-policy.test.ts src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | ## Reproduction Commands From c5868af126256a8f21983c10d9ee9a0b01094cc3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 15:18:43 -0700 Subject: [PATCH 008/224] Document terminal search effect audit (#3345) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index c9a2a1dba48..aaa94e62b56 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,12 +49,12 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026: 911 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, and #3340 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, and #3253 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, and #3344 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, and #3253 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | Renderer app shell | `src/renderer/src/App.tsx`, root components | Inventory complete, manual review in progress | Confirmation dialog active request mirror covered by #3091; onboarding settings detour reset covered by #3122; quick workspace agent selection repair covered by #3148; Cmd+J selection repair covered by #3164; smart workspace command highlight covered by #3192; App lazy-modal mount bookkeeping covered by #3220; shared reduced-motion media-query hook covered by #3287; tab-entry selected action reset covered by #3311. Continue checking global listeners, beforeunload, media-query, and active-tab repair. | -| Terminal / PTY | `components/Terminal.tsx`, `components/terminal-pane/**`, `components/terminal/**` | Inventory complete, manual review pending | Mobile driver overlay collapse repair covered by #3170; floating workspace first-open bounds normalization covered by #3269; unused legacy terminal shell/shortcut/hook files deleted by #3271; terminal tab rename focus covered by #3314 without PTY protocol changes. High-risk area: xterm lifecycle, scrollback, remote/mobile parity, focus, WebGL, resize. | +| Terminal / PTY | `components/Terminal.tsx`, `components/terminal-pane/**`, `components/terminal/**` | Inventory complete, manual review pending | Mobile driver overlay collapse repair covered by #3170; floating workspace first-open bounds normalization covered by #3269; unused legacy terminal shell/shortcut/hook files deleted by #3271; terminal tab rename focus covered by #3314 without PTY protocol changes; terminal search focus covered by #3344. High-risk area: xterm lifecycle, scrollback, remote/mobile parity, focus, WebGL, resize. | | Renderer mobile page | `components/mobile/**` | Inventory complete, manual review in progress | Mobile pairing page stage repair covered by #3180. Continue checking QR generation, mobile-page IPC, and paired-device polling. | | Browser pane | `components/browser-pane/**` | Inventory complete, manual review in progress | Highest Effect density: 62 sites in `BrowserPane.tsx`. Browser ref mirrors covered by #3081; BrowserFind debounced-query state covered by #3275. Continue with driver sync, address bar derived state, remaining find state, webview lifetime. | | Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328; diff-comment popover focus covered by #3333; markdown annotation composer focus covered by #3338. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | @@ -213,6 +213,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR ED | Diff comment popover focus | The diff-comment popover textarea focused itself from a passive Effect after the note popover mounted. | `DiffCommentPopover.tsx` covered by #3333 | Medium | | PR EE | Markdown annotation composer focus | The markdown-preview annotation composer focused its note textarea from a passive Effect after the composer mounted. | `MarkdownPreview.tsx` covered by #3338 | Medium | | PR EF | Status-bar inactive account usage fetch | Expanding the Claude account switcher kicked off inactive-account usage loading from a passive Effect after the submenu rendered. | `StatusBar.tsx` covered by #3340 | Medium | +| PR EG | Terminal search focus | Opening terminal search focused the input from a passive Effect before the remaining search-sync Effect handled xterm decoration cleanup. | `TerminalSearch.tsx` covered by #3344 | Medium | ## Merge Risk Scale @@ -354,6 +355,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3333 | `nwparker/react-perf-diff-comment-popover-focus-ref` | Diff-comment popover textarea focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/diff-comments/DiffCommentPopover.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3338 | `nwparker/react-perf-markdown-annotation-focus-ref` | Markdown annotation composer textarea focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/MarkdownPreview.test.ts src/renderer/src/components/editor/markdown-preview-links.test.ts src/renderer/src/components/editor/markdown-preview-search.test.ts src/renderer/src/components/editor/markdown-preview-controls.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3340 | `nwparker/react-perf-status-bar-account-usage-fetch` | Status-bar inactive Claude account usage fetch moves to the expand handler | Medium | Open | `pnpm exec oxlint src/renderer/src/components/status-bar/StatusBar.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/status-bar/status-bar-context-menu-policy.test.ts src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | +| #3344 | `nwparker/react-perf-terminal-search-focus-ref` | Terminal search input focus moves to callback ref while close cleanup stays in the search Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/TerminalSearch.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | ## Reproduction Commands From 52344fc7c2a779e4ab66119e5d991bf6dab1f0d2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 15:26:37 -0700 Subject: [PATCH 009/224] Document file explorer inline focus audit (#3348) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index aaa94e62b56..16c06a548d4 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,7 +49,7 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026: 911 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, and #3344 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, and #3253 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, and #3347 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, and #3253 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -60,7 +60,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328; diff-comment popover focus covered by #3333; markdown annotation composer focus covered by #3338. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | | Sidebar / worktrees | `components/sidebar/**` | Inventory complete, manual review in progress | Orca hook trust checkbox reset covered by #3089; delete dialog one-shot checkbox reset covered by #3134; Add Repo clone destination auto-fill covered by #3150; sidebar live-width mirror covered by #3160; worktree selection pruning covered by #3166; SSH reconnect prompts covered by #3267; nested repo indeterminate checkbox covered by #3294; workspace title rename focus covered by #3296; workspace title editing notification covered by #3298; worktree metadata notes resize covered by #3300. Continue with drag/drop global listeners and kanban pointer flows. | | Workspace cleanup | `components/workspace-cleanup/**` | Inventory complete, manual review in progress | Active cleanup-view repair covered by #3195. Continue checking destructive removal, repo filtering, scan refresh, and SSH/disconnected blockers. | -| Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212; file-search input focus covered by #3330. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | +| Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212; file-search input focus covered by #3330; file explorer inline create/rename focus covered by #3347. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | | Settings | `components/settings/**` | Inventory complete, manual review in progress | Repository hook local editor visibility covered by #3093; lazy section mounted-state bookkeeping covered by #3128; browser home/Kagi draft sync covered by #3136; general auto-save/Open In draft sync covered by #3138; Source Control AI instruction drafts covered by #3140; notification volume draft sync covered by #3142; repo Source Control AI override draft sync covered by #3144; Quick Commands add intent covered by #3146; Mobile QR polling count capture covered by #3152; integration preflight status derivation covered by #3154; SSH reset-relay dialog cleanup covered by #3157; diagnostics bundle cleanup ref mirror covered by #3176. Continue with draft mirrors while keeping SSH and cross-platform settings behavior intact. | | Automations | `components/automations/**` | Inventory complete, manual review in progress | Combobox open-focus Effects covered by #3100; run-history selection repair covered by #3104; external run-table selection repair covered by #3102; detail-page selection resets covered by #3106; create-from branch search reset covered by #3132. Continue with draft defaulting, polling, and completion detection. | | Issue, PR, task pages | `TaskPage.tsx`, `PullRequestPage.tsx`, `GitHubItemDialog.tsx`, `GitLabItemDialog.tsx`, Linear components | Inventory complete, manual review in progress | Linear estimate input sync covered by #3108; Linear sub-issue list sync covered by #3110; GitHub project date draft sync covered by #3112; GitHub reviewer-picker active index covered by #3114; GitHub project column state covered by #3116; GitHub Project visible-table cache covered by #3186; GitHub Project dialog state covered by #3214; GitLab comment draft reset covered by #3124; Linear title/description draft sync covered by #3130; GitHub assignee dirty-flag reset covered by #3255; GitHub reviewer-request local sync covered by #3259; GitHub link-copy feedback timers covered by #3285; GitLab task filter repair covered by #3292. Continue large-file scan with separate GitHub, GitLab, Linear, and generic review behavior. | @@ -214,6 +214,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR EE | Markdown annotation composer focus | The markdown-preview annotation composer focused its note textarea from a passive Effect after the composer mounted. | `MarkdownPreview.tsx` covered by #3338 | Medium | | PR EF | Status-bar inactive account usage fetch | Expanding the Claude account switcher kicked off inactive-account usage loading from a passive Effect after the submenu rendered. | `StatusBar.tsx` covered by #3340 | Medium | | PR EG | Terminal search focus | Opening terminal search focused the input from a passive Effect before the remaining search-sync Effect handled xterm decoration cleanup. | `TerminalSearch.tsx` covered by #3344 | Medium | +| PR EH | File explorer inline input focus | File Explorer create/rename inputs used a passive Effect to focus/select and start the blur grace-window timer after the inline row mounted. | `FileExplorerRow.tsx` covered by #3347 | Medium | ## Merge Risk Scale @@ -356,6 +357,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3338 | `nwparker/react-perf-markdown-annotation-focus-ref` | Markdown annotation composer textarea focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/MarkdownPreview.test.ts src/renderer/src/components/editor/markdown-preview-links.test.ts src/renderer/src/components/editor/markdown-preview-search.test.ts src/renderer/src/components/editor/markdown-preview-controls.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3340 | `nwparker/react-perf-status-bar-account-usage-fetch` | Status-bar inactive Claude account usage fetch moves to the expand handler | Medium | Open | `pnpm exec oxlint src/renderer/src/components/status-bar/StatusBar.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/status-bar/status-bar-context-menu-policy.test.ts src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3344 | `nwparker/react-perf-terminal-search-focus-ref` | Terminal search input focus moves to callback ref while close cleanup stays in the search Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/TerminalSearch.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | +| #3347 | `nwparker/react-perf-file-explorer-inline-focus-ref` | File Explorer inline create/rename input focus moves to a keyed callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/right-sidebar/FileExplorerRow.tsx`; `pnpm exec oxlint src/renderer/src/components/right-sidebar/FileExplorerRow.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/FileExplorer.test.tsx src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | ## Reproduction Commands From 2da2a86c3f42271ac9922851095ee48d696dbf23 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 15:32:57 -0700 Subject: [PATCH 010/224] Document font autocomplete effect audit (#3350) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index 16c06a548d4..5b90184a2be 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,7 +49,7 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026: 911 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, and #3347 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, and #3253 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, and #3349 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, and #3253 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -61,7 +61,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Sidebar / worktrees | `components/sidebar/**` | Inventory complete, manual review in progress | Orca hook trust checkbox reset covered by #3089; delete dialog one-shot checkbox reset covered by #3134; Add Repo clone destination auto-fill covered by #3150; sidebar live-width mirror covered by #3160; worktree selection pruning covered by #3166; SSH reconnect prompts covered by #3267; nested repo indeterminate checkbox covered by #3294; workspace title rename focus covered by #3296; workspace title editing notification covered by #3298; worktree metadata notes resize covered by #3300. Continue with drag/drop global listeners and kanban pointer flows. | | Workspace cleanup | `components/workspace-cleanup/**` | Inventory complete, manual review in progress | Active cleanup-view repair covered by #3195. Continue checking destructive removal, repo filtering, scan refresh, and SSH/disconnected blockers. | | Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212; file-search input focus covered by #3330; file explorer inline create/rename focus covered by #3347. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | -| Settings | `components/settings/**` | Inventory complete, manual review in progress | Repository hook local editor visibility covered by #3093; lazy section mounted-state bookkeeping covered by #3128; browser home/Kagi draft sync covered by #3136; general auto-save/Open In draft sync covered by #3138; Source Control AI instruction drafts covered by #3140; notification volume draft sync covered by #3142; repo Source Control AI override draft sync covered by #3144; Quick Commands add intent covered by #3146; Mobile QR polling count capture covered by #3152; integration preflight status derivation covered by #3154; SSH reset-relay dialog cleanup covered by #3157; diagnostics bundle cleanup ref mirror covered by #3176. Continue with draft mirrors while keeping SSH and cross-platform settings behavior intact. | +| Settings | `components/settings/**` | Inventory complete, manual review in progress | Repository hook local editor visibility covered by #3093; lazy section mounted-state bookkeeping covered by #3128; browser home/Kagi draft sync covered by #3136; general auto-save/Open In draft sync covered by #3138; Source Control AI instruction drafts covered by #3140; notification volume draft sync covered by #3142; repo Source Control AI override draft sync covered by #3144; Quick Commands add intent covered by #3146; Mobile QR polling count capture covered by #3152; integration preflight status derivation covered by #3154; SSH reset-relay dialog cleanup covered by #3157; diagnostics bundle cleanup ref mirror covered by #3176; font autocomplete option scroll covered by #3349. Continue with draft mirrors while keeping SSH and cross-platform settings behavior intact. | | Automations | `components/automations/**` | Inventory complete, manual review in progress | Combobox open-focus Effects covered by #3100; run-history selection repair covered by #3104; external run-table selection repair covered by #3102; detail-page selection resets covered by #3106; create-from branch search reset covered by #3132. Continue with draft defaulting, polling, and completion detection. | | Issue, PR, task pages | `TaskPage.tsx`, `PullRequestPage.tsx`, `GitHubItemDialog.tsx`, `GitLabItemDialog.tsx`, Linear components | Inventory complete, manual review in progress | Linear estimate input sync covered by #3108; Linear sub-issue list sync covered by #3110; GitHub project date draft sync covered by #3112; GitHub reviewer-picker active index covered by #3114; GitHub project column state covered by #3116; GitHub Project visible-table cache covered by #3186; GitHub Project dialog state covered by #3214; GitLab comment draft reset covered by #3124; Linear title/description draft sync covered by #3130; GitHub assignee dirty-flag reset covered by #3255; GitHub reviewer-request local sync covered by #3259; GitHub link-copy feedback timers covered by #3285; GitLab task filter repair covered by #3292. Continue large-file scan with separate GitHub, GitLab, Linear, and generic review behavior. | | Onboarding / feature wall | `components/onboarding/**`, `components/feature-wall/**` | Inventory complete, manual review in progress | Select portal root Effects covered by #3087; tour visited-marker Effects covered by #3095; persisted completion close reset covered by #3188; onboarding settings hydration covered by #3198; unused orchestration notifications page deleted by #3273; feature-wall skill installed-state notification Effects covered by #3281; session-depth open publish Effects covered by #3283; shared reduced-motion media-query hook covered by #3287. Continue with demo timers and telemetry while avoiding telemetry semantic changes. | @@ -215,6 +215,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR EF | Status-bar inactive account usage fetch | Expanding the Claude account switcher kicked off inactive-account usage loading from a passive Effect after the submenu rendered. | `StatusBar.tsx` covered by #3340 | Medium | | PR EG | Terminal search focus | Opening terminal search focused the input from a passive Effect before the remaining search-sync Effect handled xterm decoration cleanup. | `TerminalSearch.tsx` covered by #3344 | Medium | | PR EH | File explorer inline input focus | File Explorer create/rename inputs used a passive Effect to focus/select and start the blur grace-window timer after the inline row mounted. | `FileExplorerRow.tsx` covered by #3347 | Medium | +| PR EI | Font autocomplete highlighted-option scroll | Settings font autocomplete scrolled the highlighted option from a passive Effect after each keyboard or pointer highlight change. | `SettingsFormControls.tsx` covered by #3349 | Medium | ## Merge Risk Scale @@ -358,6 +359,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3340 | `nwparker/react-perf-status-bar-account-usage-fetch` | Status-bar inactive Claude account usage fetch moves to the expand handler | Medium | Open | `pnpm exec oxlint src/renderer/src/components/status-bar/StatusBar.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/status-bar/status-bar-context-menu-policy.test.ts src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3344 | `nwparker/react-perf-terminal-search-focus-ref` | Terminal search input focus moves to callback ref while close cleanup stays in the search Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/TerminalSearch.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3347 | `nwparker/react-perf-file-explorer-inline-focus-ref` | File Explorer inline create/rename input focus moves to a keyed callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/right-sidebar/FileExplorerRow.tsx`; `pnpm exec oxlint src/renderer/src/components/right-sidebar/FileExplorerRow.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/FileExplorer.test.tsx src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | +| #3349 | `nwparker/react-perf-font-autocomplete-scroll-ref` | Settings font autocomplete highlighted-option scroll moves to the option ref callback | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/settings/SettingsFormControls.tsx`; `pnpm exec oxlint src/renderer/src/components/settings/SettingsFormControls.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx src/renderer/src/components/settings/TerminalPane.pwsh.test.ts src/renderer/src/components/settings/TerminalPane.ghostty.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | ## Reproduction Commands From 5ed3c1bc51045b0294af6d4c9a5dc5f763bf9013 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 22:34:55 +0000 Subject: [PATCH 011/224] release: v1.4.36-rc.0 [rc-slot:2026-05-29-15] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 99609060790..da2a7c5e2b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.35", + "version": "1.4.36-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 498a824fa91aeabfa39b06dc7865e31db3d92da9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 15:45:03 -0700 Subject: [PATCH 012/224] Document SSH passphrase focus audit (#3353) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index 5b90184a2be..50c9c054515 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,7 +49,7 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026: 911 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, and #3349 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, and #3253 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, and #3349 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, #3253, and #3351 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -61,7 +61,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Sidebar / worktrees | `components/sidebar/**` | Inventory complete, manual review in progress | Orca hook trust checkbox reset covered by #3089; delete dialog one-shot checkbox reset covered by #3134; Add Repo clone destination auto-fill covered by #3150; sidebar live-width mirror covered by #3160; worktree selection pruning covered by #3166; SSH reconnect prompts covered by #3267; nested repo indeterminate checkbox covered by #3294; workspace title rename focus covered by #3296; workspace title editing notification covered by #3298; worktree metadata notes resize covered by #3300. Continue with drag/drop global listeners and kanban pointer flows. | | Workspace cleanup | `components/workspace-cleanup/**` | Inventory complete, manual review in progress | Active cleanup-view repair covered by #3195. Continue checking destructive removal, repo filtering, scan refresh, and SSH/disconnected blockers. | | Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212; file-search input focus covered by #3330; file explorer inline create/rename focus covered by #3347. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | -| Settings | `components/settings/**` | Inventory complete, manual review in progress | Repository hook local editor visibility covered by #3093; lazy section mounted-state bookkeeping covered by #3128; browser home/Kagi draft sync covered by #3136; general auto-save/Open In draft sync covered by #3138; Source Control AI instruction drafts covered by #3140; notification volume draft sync covered by #3142; repo Source Control AI override draft sync covered by #3144; Quick Commands add intent covered by #3146; Mobile QR polling count capture covered by #3152; integration preflight status derivation covered by #3154; SSH reset-relay dialog cleanup covered by #3157; diagnostics bundle cleanup ref mirror covered by #3176; font autocomplete option scroll covered by #3349. Continue with draft mirrors while keeping SSH and cross-platform settings behavior intact. | +| Settings | `components/settings/**` | Inventory complete, manual review in progress | Repository hook local editor visibility covered by #3093; lazy section mounted-state bookkeeping covered by #3128; browser home/Kagi draft sync covered by #3136; general auto-save/Open In draft sync covered by #3138; Source Control AI instruction drafts covered by #3140; notification volume draft sync covered by #3142; repo Source Control AI override draft sync covered by #3144; Quick Commands add intent covered by #3146; Mobile QR polling count capture covered by #3152; integration preflight status derivation covered by #3154; SSH reset-relay dialog cleanup covered by #3157; diagnostics bundle cleanup ref mirror covered by #3176; font autocomplete option scroll covered by #3349; SSH passphrase prompt focus covered by #3351. Continue with draft mirrors while keeping SSH and cross-platform settings behavior intact. | | Automations | `components/automations/**` | Inventory complete, manual review in progress | Combobox open-focus Effects covered by #3100; run-history selection repair covered by #3104; external run-table selection repair covered by #3102; detail-page selection resets covered by #3106; create-from branch search reset covered by #3132. Continue with draft defaulting, polling, and completion detection. | | Issue, PR, task pages | `TaskPage.tsx`, `PullRequestPage.tsx`, `GitHubItemDialog.tsx`, `GitLabItemDialog.tsx`, Linear components | Inventory complete, manual review in progress | Linear estimate input sync covered by #3108; Linear sub-issue list sync covered by #3110; GitHub project date draft sync covered by #3112; GitHub reviewer-picker active index covered by #3114; GitHub project column state covered by #3116; GitHub Project visible-table cache covered by #3186; GitHub Project dialog state covered by #3214; GitLab comment draft reset covered by #3124; Linear title/description draft sync covered by #3130; GitHub assignee dirty-flag reset covered by #3255; GitHub reviewer-request local sync covered by #3259; GitHub link-copy feedback timers covered by #3285; GitLab task filter repair covered by #3292. Continue large-file scan with separate GitHub, GitLab, Linear, and generic review behavior. | | Onboarding / feature wall | `components/onboarding/**`, `components/feature-wall/**` | Inventory complete, manual review in progress | Select portal root Effects covered by #3087; tour visited-marker Effects covered by #3095; persisted completion close reset covered by #3188; onboarding settings hydration covered by #3198; unused orchestration notifications page deleted by #3273; feature-wall skill installed-state notification Effects covered by #3281; session-depth open publish Effects covered by #3283; shared reduced-motion media-query hook covered by #3287. Continue with demo timers and telemetry while avoiding telemetry semantic changes. | @@ -216,6 +216,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR EG | Terminal search focus | Opening terminal search focused the input from a passive Effect before the remaining search-sync Effect handled xterm decoration cleanup. | `TerminalSearch.tsx` covered by #3344 | Medium | | PR EH | File explorer inline input focus | File Explorer create/rename inputs used a passive Effect to focus/select and start the blur grace-window timer after the inline row mounted. | `FileExplorerRow.tsx` covered by #3347 | Medium | | PR EI | Font autocomplete highlighted-option scroll | Settings font autocomplete scrolled the highlighted option from a passive Effect after each keyboard or pointer highlight change. | `SettingsFormControls.tsx` covered by #3349 | Medium | +| PR EJ | SSH passphrase prompt focus | New SSH password/passphrase requests focused the credential input from a passive Effect keyed by request id. | `SshPassphraseDialog.tsx` covered by #3351 | High | ## Merge Risk Scale @@ -360,6 +361,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3344 | `nwparker/react-perf-terminal-search-focus-ref` | Terminal search input focus moves to callback ref while close cleanup stays in the search Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/TerminalSearch.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3347 | `nwparker/react-perf-file-explorer-inline-focus-ref` | File Explorer inline create/rename input focus moves to a keyed callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/right-sidebar/FileExplorerRow.tsx`; `pnpm exec oxlint src/renderer/src/components/right-sidebar/FileExplorerRow.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/FileExplorer.test.tsx src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3349 | `nwparker/react-perf-font-autocomplete-scroll-ref` | Settings font autocomplete highlighted-option scroll moves to the option ref callback | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/settings/SettingsFormControls.tsx`; `pnpm exec oxlint src/renderer/src/components/settings/SettingsFormControls.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx src/renderer/src/components/settings/TerminalPane.pwsh.test.ts src/renderer/src/components/settings/TerminalPane.ghostty.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | +| #3351 | `nwparker/react-perf-ssh-passphrase-focus-ref` | SSH credential dialog input focus moves to callback ref | High | Open | `pnpm exec oxfmt --write src/renderer/src/components/settings/SshPassphraseDialog.tsx`; `pnpm exec oxlint src/renderer/src/components/settings/SshPassphraseDialog.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/ssh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | ## Reproduction Commands From c7cd858503dd6d0243707e19067b7457f78f5eba Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 15:51:55 -0700 Subject: [PATCH 013/224] Document diff comment card resize audit (#3355) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index 50c9c054515..b366040a173 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,7 +49,7 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026: 911 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, and #3349 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, #3253, and #3351 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, #3349, and #3354 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, #3253, and #3351 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -57,7 +57,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Terminal / PTY | `components/Terminal.tsx`, `components/terminal-pane/**`, `components/terminal/**` | Inventory complete, manual review pending | Mobile driver overlay collapse repair covered by #3170; floating workspace first-open bounds normalization covered by #3269; unused legacy terminal shell/shortcut/hook files deleted by #3271; terminal tab rename focus covered by #3314 without PTY protocol changes; terminal search focus covered by #3344. High-risk area: xterm lifecycle, scrollback, remote/mobile parity, focus, WebGL, resize. | | Renderer mobile page | `components/mobile/**` | Inventory complete, manual review in progress | Mobile pairing page stage repair covered by #3180. Continue checking QR generation, mobile-page IPC, and paired-device polling. | | Browser pane | `components/browser-pane/**` | Inventory complete, manual review in progress | Highest Effect density: 62 sites in `BrowserPane.tsx`. Browser ref mirrors covered by #3081; BrowserFind debounced-query state covered by #3275. Continue with driver sync, address bar derived state, remaining find state, webview lifetime. | -| Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328; diff-comment popover focus covered by #3333; markdown annotation composer focus covered by #3338. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | +| Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328; diff-comment popover focus covered by #3333; markdown annotation composer focus covered by #3338; diff-comment card resize covered by #3354. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | | Sidebar / worktrees | `components/sidebar/**` | Inventory complete, manual review in progress | Orca hook trust checkbox reset covered by #3089; delete dialog one-shot checkbox reset covered by #3134; Add Repo clone destination auto-fill covered by #3150; sidebar live-width mirror covered by #3160; worktree selection pruning covered by #3166; SSH reconnect prompts covered by #3267; nested repo indeterminate checkbox covered by #3294; workspace title rename focus covered by #3296; workspace title editing notification covered by #3298; worktree metadata notes resize covered by #3300. Continue with drag/drop global listeners and kanban pointer flows. | | Workspace cleanup | `components/workspace-cleanup/**` | Inventory complete, manual review in progress | Active cleanup-view repair covered by #3195. Continue checking destructive removal, repo filtering, scan refresh, and SSH/disconnected blockers. | | Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212; file-search input focus covered by #3330; file explorer inline create/rename focus covered by #3347. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | @@ -217,6 +217,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR EH | File explorer inline input focus | File Explorer create/rename inputs used a passive Effect to focus/select and start the blur grace-window timer after the inline row mounted. | `FileExplorerRow.tsx` covered by #3347 | Medium | | PR EI | Font autocomplete highlighted-option scroll | Settings font autocomplete scrolled the highlighted option from a passive Effect after each keyboard or pointer highlight change. | `SettingsFormControls.tsx` covered by #3349 | Medium | | PR EJ | SSH passphrase prompt focus | New SSH password/passphrase requests focused the credential input from a passive Effect keyed by request id. | `SshPassphraseDialog.tsx` covered by #3351 | High | +| PR EK | Diff comment card resize | Diff comment cards remeasured Monaco view-zone content from a passive editing-state Effect after save or cancel closed edit mode. | `DiffCommentCard.tsx` covered by #3354 | Medium | ## Merge Risk Scale @@ -362,6 +363,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3347 | `nwparker/react-perf-file-explorer-inline-focus-ref` | File Explorer inline create/rename input focus moves to a keyed callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/right-sidebar/FileExplorerRow.tsx`; `pnpm exec oxlint src/renderer/src/components/right-sidebar/FileExplorerRow.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/FileExplorer.test.tsx src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3349 | `nwparker/react-perf-font-autocomplete-scroll-ref` | Settings font autocomplete highlighted-option scroll moves to the option ref callback | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/settings/SettingsFormControls.tsx`; `pnpm exec oxlint src/renderer/src/components/settings/SettingsFormControls.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx src/renderer/src/components/settings/TerminalPane.pwsh.test.ts src/renderer/src/components/settings/TerminalPane.ghostty.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3351 | `nwparker/react-perf-ssh-passphrase-focus-ref` | SSH credential dialog input focus moves to callback ref | High | Open | `pnpm exec oxfmt --write src/renderer/src/components/settings/SshPassphraseDialog.tsx`; `pnpm exec oxlint src/renderer/src/components/settings/SshPassphraseDialog.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/ssh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | +| #3354 | `nwparker/react-perf-diff-comment-card-resize` | Diff comment card close-after-edit resize moves to save/cancel handlers | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/diff-comments/DiffCommentCard.tsx`; `pnpm exec oxlint src/renderer/src/components/diff-comments/DiffCommentCard.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | ## Reproduction Commands From f150310561f78c29d5a893ebbf38fb5ee8d08e65 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 15:58:04 -0700 Subject: [PATCH 014/224] Document contextual copy cleanup audit (#3357) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index b366040a173..d85629e2dd0 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,7 +49,7 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026: 911 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, #3349, and #3354 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, #3253, and #3351 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, #3349, #3354, and #3356 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, #3253, and #3351 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -57,7 +57,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Terminal / PTY | `components/Terminal.tsx`, `components/terminal-pane/**`, `components/terminal/**` | Inventory complete, manual review pending | Mobile driver overlay collapse repair covered by #3170; floating workspace first-open bounds normalization covered by #3269; unused legacy terminal shell/shortcut/hook files deleted by #3271; terminal tab rename focus covered by #3314 without PTY protocol changes; terminal search focus covered by #3344. High-risk area: xterm lifecycle, scrollback, remote/mobile parity, focus, WebGL, resize. | | Renderer mobile page | `components/mobile/**` | Inventory complete, manual review in progress | Mobile pairing page stage repair covered by #3180. Continue checking QR generation, mobile-page IPC, and paired-device polling. | | Browser pane | `components/browser-pane/**` | Inventory complete, manual review in progress | Highest Effect density: 62 sites in `BrowserPane.tsx`. Browser ref mirrors covered by #3081; BrowserFind debounced-query state covered by #3275. Continue with driver sync, address bar derived state, remaining find state, webview lifetime. | -| Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328; diff-comment popover focus covered by #3333; markdown annotation composer focus covered by #3338; diff-comment card resize covered by #3354. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | +| Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328; diff-comment popover focus covered by #3333; markdown annotation composer focus covered by #3338; diff-comment card resize covered by #3354; contextual-copy toast cleanup covered by #3356. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | | Sidebar / worktrees | `components/sidebar/**` | Inventory complete, manual review in progress | Orca hook trust checkbox reset covered by #3089; delete dialog one-shot checkbox reset covered by #3134; Add Repo clone destination auto-fill covered by #3150; sidebar live-width mirror covered by #3160; worktree selection pruning covered by #3166; SSH reconnect prompts covered by #3267; nested repo indeterminate checkbox covered by #3294; workspace title rename focus covered by #3296; workspace title editing notification covered by #3298; worktree metadata notes resize covered by #3300. Continue with drag/drop global listeners and kanban pointer flows. | | Workspace cleanup | `components/workspace-cleanup/**` | Inventory complete, manual review in progress | Active cleanup-view repair covered by #3195. Continue checking destructive removal, repo filtering, scan refresh, and SSH/disconnected blockers. | | Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212; file-search input focus covered by #3330; file explorer inline create/rename focus covered by #3347. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | @@ -218,6 +218,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR EI | Font autocomplete highlighted-option scroll | Settings font autocomplete scrolled the highlighted option from a passive Effect after each keyboard or pointer highlight change. | `SettingsFormControls.tsx` covered by #3349 | Medium | | PR EJ | SSH passphrase prompt focus | New SSH password/passphrase requests focused the credential input from a passive Effect keyed by request id. | `SshPassphraseDialog.tsx` covered by #3351 | High | | PR EK | Diff comment card resize | Diff comment cards remeasured Monaco view-zone content from a passive editing-state Effect after save or cancel closed edit mode. | `DiffCommentCard.tsx` covered by #3354 | Medium | +| PR EL | Contextual-copy toast cleanup | The editor contextual-copy hook used a passive mount Effect only to clear its confirmation-toast timeout on unmount. | `useContextualCopySetup.tsx`, `setup-contextual-copy.ts` covered by #3356 | Medium | ## Merge Risk Scale @@ -364,6 +365,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3349 | `nwparker/react-perf-font-autocomplete-scroll-ref` | Settings font autocomplete highlighted-option scroll moves to the option ref callback | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/settings/SettingsFormControls.tsx`; `pnpm exec oxlint src/renderer/src/components/settings/SettingsFormControls.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx src/renderer/src/components/settings/TerminalPane.pwsh.test.ts src/renderer/src/components/settings/TerminalPane.ghostty.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3351 | `nwparker/react-perf-ssh-passphrase-focus-ref` | SSH credential dialog input focus moves to callback ref | High | Open | `pnpm exec oxfmt --write src/renderer/src/components/settings/SshPassphraseDialog.tsx`; `pnpm exec oxlint src/renderer/src/components/settings/SshPassphraseDialog.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/ssh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3354 | `nwparker/react-perf-diff-comment-card-resize` | Diff comment card close-after-edit resize moves to save/cancel handlers | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/diff-comments/DiffCommentCard.tsx`; `pnpm exec oxlint src/renderer/src/components/diff-comments/DiffCommentCard.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | +| #3356 | `nwparker/react-perf-contextual-copy-timer-dispose` | Contextual-copy toast timeout cleanup moves to Monaco editor disposal | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/useContextualCopySetup.tsx src/renderer/src/components/editor/setup-contextual-copy.ts`; `pnpm exec oxlint src/renderer/src/components/editor/useContextualCopySetup.tsx src/renderer/src/components/editor/setup-contextual-copy.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/selection-copy.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | ## Reproduction Commands From 5ec0e9ad5bf8bacadbde54efe340bae23336cf84 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 16:05:24 -0700 Subject: [PATCH 015/224] Document Space page interaction audit (#3362) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index d85629e2dd0..d54e17f2d94 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,7 +49,7 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026: 911 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, #3349, #3354, and #3356 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, #3253, and #3351 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, #3349, #3354, #3356, and #3361 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, #3253, and #3351 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -65,7 +65,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Automations | `components/automations/**` | Inventory complete, manual review in progress | Combobox open-focus Effects covered by #3100; run-history selection repair covered by #3104; external run-table selection repair covered by #3102; detail-page selection resets covered by #3106; create-from branch search reset covered by #3132. Continue with draft defaulting, polling, and completion detection. | | Issue, PR, task pages | `TaskPage.tsx`, `PullRequestPage.tsx`, `GitHubItemDialog.tsx`, `GitLabItemDialog.tsx`, Linear components | Inventory complete, manual review in progress | Linear estimate input sync covered by #3108; Linear sub-issue list sync covered by #3110; GitHub project date draft sync covered by #3112; GitHub reviewer-picker active index covered by #3114; GitHub project column state covered by #3116; GitHub Project visible-table cache covered by #3186; GitHub Project dialog state covered by #3214; GitLab comment draft reset covered by #3124; Linear title/description draft sync covered by #3130; GitHub assignee dirty-flag reset covered by #3255; GitHub reviewer-request local sync covered by #3259; GitHub link-copy feedback timers covered by #3285; GitLab task filter repair covered by #3292. Continue large-file scan with separate GitHub, GitLab, Linear, and generic review behavior. | | Onboarding / feature wall | `components/onboarding/**`, `components/feature-wall/**` | Inventory complete, manual review in progress | Select portal root Effects covered by #3087; tour visited-marker Effects covered by #3095; persisted completion close reset covered by #3188; onboarding settings hydration covered by #3198; unused orchestration notifications page deleted by #3273; feature-wall skill installed-state notification Effects covered by #3281; session-depth open publish Effects covered by #3283; shared reduced-motion media-query hook covered by #3287. Continue with demo timers and telemetry while avoiding telemetry semantic changes. | -| Status, dashboard, activity | `components/status-bar/**`, `components/dashboard/**`, `components/activity/**`, `components/pet/**` | Inventory complete, manual review in progress | Status-bar account menu close reset covered by #3051; pet overlay size clamp covered by #3118; Activity selected-thread repair covered by #3126; Workspace Space row-state repairs covered by #3168; Resource Manager Space scan-ready latch covered by #3200; pet reduced-motion subscription covered by #3287; status-bar account usage fetch covered by #3340. Check interval sharing, retained agent state, activity terminal portals. | +| Status, dashboard, activity | `components/status-bar/**`, `components/dashboard/**`, `components/activity/**`, `components/pet/**` | Inventory complete, manual review in progress | Status-bar account menu close reset covered by #3051; pet overlay size clamp covered by #3118; Activity selected-thread repair covered by #3126; Workspace Space row-state repairs covered by #3168; Resource Manager Space scan-ready latch covered by #3200; pet reduced-motion subscription covered by #3287; status-bar account usage fetch covered by #3340; Space page open interaction covered by #3361. Check interval sharing, retained agent state, activity terminal portals. | | Mobile app routes | `mobile/app/**` | Inventory complete, manual review in progress | Pair-confirm route-code parsing covered by #3240; host home new-worktree route action covered by #3242; Accounts unused client ref covered by #3244; mobile home shared-client ref mirror covered by #3247; session route terminal/tab ref mirrors covered by #3250; tasks route client ref mirror covered by #3253; tasks route workspace agent selection covered by #3261. Continue with large `tasks.tsx` state/polling Effects, session route cleanup Effects, home route ref mirrors, and remote-client parity. | | Mobile shared source | `mobile/src/**` | Inventory complete, manual review in progress | TextInputModal and CustomKeyModal open-state resets covered by #3223; BottomDrawer mount bookkeeping covered by #3226; MobileBrowserPane address sync covered by #3228; MobileBrowserPane ref mirrors covered by #3230; useMobileDictation ref mirrors covered by #3234; NewWorktreeModal agent selection covered by #3238. Continue with transport client context plus NewWorktreeModal open/reset, SSH, agent-detection, and setup-hook fetch Effects. | | Expo two-way audio hook | `mobile/packages/expo-two-way-audio/src/hooks.ts` | Inventory complete, manual review pending | Single Effect plus `useSyncExternalStore`; verify native subscription cleanup. | @@ -219,6 +219,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR EJ | SSH passphrase prompt focus | New SSH password/passphrase requests focused the credential input from a passive Effect keyed by request id. | `SshPassphraseDialog.tsx` covered by #3351 | High | | PR EK | Diff comment card resize | Diff comment cards remeasured Monaco view-zone content from a passive editing-state Effect after save or cancel closed edit mode. | `DiffCommentCard.tsx` covered by #3354 | Medium | | PR EL | Contextual-copy toast cleanup | The editor contextual-copy hook used a passive mount Effect only to clear its confirmation-toast timeout on unmount. | `useContextualCopySetup.tsx`, `setup-contextual-copy.ts` covered by #3356 | Medium | +| PR EM | Space page open interaction | The Space page recorded workspace-cleanup feature usage from a passive mount Effect after the page rendered. | `WorkspaceSpacePage.tsx`, `ui.ts` covered by #3361 | Medium | ## Merge Risk Scale @@ -366,6 +367,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3351 | `nwparker/react-perf-ssh-passphrase-focus-ref` | SSH credential dialog input focus moves to callback ref | High | Open | `pnpm exec oxfmt --write src/renderer/src/components/settings/SshPassphraseDialog.tsx`; `pnpm exec oxlint src/renderer/src/components/settings/SshPassphraseDialog.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/ssh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3354 | `nwparker/react-perf-diff-comment-card-resize` | Diff comment card close-after-edit resize moves to save/cancel handlers | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/diff-comments/DiffCommentCard.tsx`; `pnpm exec oxlint src/renderer/src/components/diff-comments/DiffCommentCard.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3356 | `nwparker/react-perf-contextual-copy-timer-dispose` | Contextual-copy toast timeout cleanup moves to Monaco editor disposal | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/useContextualCopySetup.tsx src/renderer/src/components/editor/setup-contextual-copy.ts`; `pnpm exec oxlint src/renderer/src/components/editor/useContextualCopySetup.tsx src/renderer/src/components/editor/setup-contextual-copy.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/selection-copy.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | +| #3361 | `nwparker/react-perf-space-page-interaction` | Space page workspace-cleanup interaction records in `openSpacePage` | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx src/renderer/src/store/slices/ui.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm exec oxlint src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx src/renderer/src/store/slices/ui.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | ## Reproduction Commands From 0d060e1d21c2a7ab95a8a0ed0bf186757c7c65d9 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 29 May 2026 19:06:33 -0400 Subject: [PATCH 016/224] Make WSL first-class for local agents and provider accounts (#2649) --- .../runtime-auth-service.test.ts | 98 ++- .../claude-accounts/runtime-auth-service.ts | 224 +++++- .../claude-accounts/runtime-selection.test.ts | 107 +++ src/main/claude-accounts/runtime-selection.ts | 147 ++++ src/main/claude-accounts/service.test.ts | 228 +++++- src/main/claude-accounts/service.ts | 436 ++++++++-- .../runtime-home-service.test.ts | 663 +++++++++++++++- .../codex-accounts/runtime-home-service.ts | 327 +++++++- .../codex-accounts/runtime-selection.test.ts | 107 +++ src/main/codex-accounts/runtime-selection.ts | 146 ++++ src/main/codex-accounts/service.test.ts | 390 +++++++++ src/main/codex-accounts/service.ts | 410 ++++++++-- src/main/daemon/daemon-pty-adapter.ts | 1 + src/main/daemon/daemon-server.ts | 4 + src/main/daemon/pty-subprocess.test.ts | 130 ++- src/main/daemon/pty-subprocess.ts | 71 +- src/main/daemon/terminal-host.ts | 3 + src/main/daemon/types.ts | 2 + src/main/daemon/wsl-session-context.ts | 3 +- src/main/index.ts | 49 +- src/main/ipc/app.ts | 3 +- src/main/ipc/claude-accounts.ts | 17 +- src/main/ipc/codex-accounts.ts | 20 +- src/main/ipc/preflight.test.ts | 32 +- src/main/ipc/preflight.ts | 68 +- src/main/ipc/pty.ts | 117 ++- src/main/ipc/rate-limits.ts | 7 + src/main/providers/local-pty-provider.test.ts | 101 +++ src/main/providers/local-pty-provider.ts | 84 +- src/main/providers/types.ts | 3 + src/main/providers/windows-shell-args.test.ts | 2 +- src/main/providers/windows-shell-args.ts | 5 +- src/main/pty/codex-home-wsl-env.test.ts | 8 +- src/main/pty/codex-home-wsl-env.ts | 8 + src/main/rate-limits/claude-fetcher.test.ts | 29 +- src/main/rate-limits/claude-fetcher.ts | 54 ++ src/main/rate-limits/claude-pty.ts | 30 +- .../claude-rate-limit-target.test.ts | 102 +++ .../rate-limits/claude-rate-limit-target.ts | 71 ++ src/main/rate-limits/codex-fetcher.test.ts | 151 ++++ src/main/rate-limits/codex-fetcher.ts | 83 +- .../codex-rate-limit-target.test.ts | 102 +++ .../rate-limits/codex-rate-limit-target.ts | 73 ++ src/main/rate-limits/service.test.ts | 195 +++++ src/main/rate-limits/service.ts | 205 ++++- src/main/runtime/orca-runtime-files.test.ts | 4 +- .../commit-message-agent-environment.test.ts | 34 +- .../commit-message-agent-environment.ts | 6 + .../window/attach-main-window-services.ts | 8 +- src/main/wsl-bash-command.test.ts | 23 + src/main/wsl-bash-command.ts | 10 + src/main/wsl-env.ts | 17 + src/preload/api-types.ts | 40 +- src/preload/index.ts | 37 +- .../src/components/settings/AccountsPane.tsx | 429 +++++++--- .../settings/AgentLocationSetting.tsx | 126 +++ .../components/settings/AgentsPane.test.tsx | 47 +- .../src/components/settings/AgentsPane.tsx | 38 +- .../src/components/settings/Settings.tsx | 24 +- .../settings/SettingsFormControls.tsx | 12 +- .../settings/TerminalPane.pwsh.test.ts | 28 + .../src/components/settings/TerminalPane.tsx | 52 ++ .../components/settings/accounts-search.ts | 10 + .../src/components/settings/agents-search.ts | 5 + .../settings/terminal-windows-search.ts | 19 + .../src/components/status-bar/StatusBar.tsx | 746 +++++++++++++++--- .../status-bar-runtime-groups.test.ts | 107 +++ .../TabBar.windows-shell-launch.test.ts | 10 +- .../src/lib/local-preflight-context.test.ts | 48 ++ .../src/lib/local-preflight-context.ts | 45 +- .../lib/windows-terminal-capabilities.test.ts | 34 +- .../src/lib/windows-terminal-capabilities.ts | 23 +- .../src/store/slices/detected-agents.test.ts | 65 ++ .../src/store/slices/detected-agents.ts | 9 +- src/renderer/src/store/slices/preflight.ts | 2 +- src/renderer/src/store/slices/rate-limits.ts | 68 +- src/renderer/src/web/web-preload-api.ts | 12 +- src/shared/constants.ts | 4 + src/shared/rate-limit-types.ts | 7 + src/shared/types.ts | 38 + 80 files changed, 6757 insertions(+), 546 deletions(-) create mode 100644 src/main/claude-accounts/runtime-selection.test.ts create mode 100644 src/main/claude-accounts/runtime-selection.ts create mode 100644 src/main/codex-accounts/runtime-selection.test.ts create mode 100644 src/main/codex-accounts/runtime-selection.ts create mode 100644 src/main/rate-limits/claude-rate-limit-target.test.ts create mode 100644 src/main/rate-limits/claude-rate-limit-target.ts create mode 100644 src/main/rate-limits/codex-rate-limit-target.test.ts create mode 100644 src/main/rate-limits/codex-rate-limit-target.ts create mode 100644 src/main/wsl-bash-command.test.ts create mode 100644 src/main/wsl-bash-command.ts create mode 100644 src/main/wsl-env.ts create mode 100644 src/renderer/src/components/settings/AgentLocationSetting.tsx create mode 100644 src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index 233db78d2b8..962e4795a02 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -3132,7 +3132,10 @@ describe('ClaudeRuntimeAuthService', () => { const service = new ClaudeRuntimeAuthService(store as never) const preparation = await service.prepareForClaudeLaunch() - expect(store.updateSettings).toHaveBeenCalledWith({ activeClaudeManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith({ + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + }) expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude')) expect(preparation.stripAuthEnv).toBe(false) expect(preparation.provenance).toBe('system') @@ -3337,6 +3340,99 @@ describe('ClaudeRuntimeAuthService', () => { expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) }) + it('clears a selected WSL managed account when its credentials are missing', async () => { + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/account-1/auth' + }) + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch({ + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(store.updateSettings).toHaveBeenCalledWith({ + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + expect(preparation.runtime).toBe('wsl') + expect(preparation.provenance).toBe('wsl:Ubuntu:system') + expect(preparation.stripAuthEnv).toBe(true) + }) + + it('uses the default distro selection for WSL-default Claude preparation', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => join(testState.userDataDir, 'wsl-home') + })) + const ubuntuAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'ubuntu-account', + createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') + ) + const debianAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'debian-account', + createClaudeCredentialsJson('debian@example.com', 'debian-token') + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('ubuntu-account', ubuntuAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' + }), + createClaudeAccount('debian-account', debianAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Debian', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/debian/auth' + }) + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'ubuntu-account', Debian: 'debian-account' } + } + }) + const store = createStore(settings) + + try { + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch({ + runtime: 'wsl', + wslDistro: null + }) + + expect(preparation).toMatchObject({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth', + provenance: 'managed:ubuntu-account:wsl:Ubuntu', + stripAuthEnv: true + }) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + it('does not clobber fresh Claude credentials after clearLastWrittenCredentialsJson', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index f41a0fbc6bc..2a2081bb695 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: Claude account switching has one safety boundary: runtime auth materialization. Keeping file, Keychain, snapshot, and env-patch semantics together prevents PTY launch and quota fetch paths drifting. */ +import { execFileSync } from 'node:child_process' import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' import { app } from 'electron' @@ -13,6 +14,9 @@ import { resolveOwnedClaudeManagedAuthPath, writeClaudeManagedAuthFile } from './managed-auth-path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { getDefaultWslDistro, getWslHome, toWindowsWslPath } from '../wsl' +import { buildEncodedWslBashCommand } from '../wsl-bash-command' import { hasLiveClaudePtys } from './live-pty-gate' import { ClaudeRuntimePathResolver } from './runtime-paths' import { @@ -24,9 +28,19 @@ import { writeActiveClaudeKeychainCredentialsForRuntime, writeManagedClaudeKeychainCredentials } from './keychain' +import { + getSelectedClaudeAccountIdForTarget, + normalizeClaudeAccountSelectionTarget, + normalizeClaudeRuntimeSelection, + setSelectedClaudeAccountIdForTarget, + type ClaudeAccountSelectionTarget +} from './runtime-selection' export type ClaudeRuntimeAuthPreparation = { configDir: string + runtime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxConfigDir?: string | null envPatch: ClaudeEnvPatch stripAuthEnv: boolean provenance: string @@ -64,6 +78,10 @@ type ClaudeRefreshTokenComparison = 'same' | 'different' | 'missing' const RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR = Symbol('runtime-oauth-account-parse-error') +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + export class ClaudeRuntimeAuthService { private readonly pathResolver = new ClaudeRuntimePathResolver() private mutationQueue: Promise = Promise.resolve() @@ -83,18 +101,22 @@ export class ClaudeRuntimeAuthService { void this.safeSyncForCurrentSelection() } - async prepareForClaudeLaunch(): Promise { - await this.syncForCurrentSelection() - return this.getPreparation() + async prepareForClaudeLaunch( + target?: ClaudeAccountSelectionTarget + ): Promise { + await this.syncForCurrentSelection(target) + return this.getPreparation(target) } - async prepareForRateLimitFetch(): Promise { - await this.syncForCurrentSelection() - return this.getPreparation() + async prepareForRateLimitFetch( + target?: ClaudeAccountSelectionTarget + ): Promise { + await this.syncForCurrentSelection(target) + return this.getPreparation(target) } - async syncForCurrentSelection(): Promise { - await this.serializeMutation(() => this.doSyncForCurrentSelection()) + async syncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { + await this.serializeMutation(() => this.doSyncForCurrentSelection(target)) } async forceMaterializeCurrentSelectionForRollback(): Promise { @@ -122,7 +144,7 @@ export class ClaudeRuntimeAuthService { private initializeLastSyncedState(): void { const settings = this.store.getSettings() - this.lastSyncedAccountId = settings.activeClaudeManagedAccountId + this.lastSyncedAccountId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) } private async safeSyncForCurrentSelection(): Promise { @@ -139,12 +161,12 @@ export class ClaudeRuntimeAuthService { return next } - private async doSyncForCurrentSelection(): Promise { + private async doSyncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { const settings = this.store.getSettings() - const activeAccount = this.getActiveAccount( - settings.claudeManagedAccounts, - settings.activeClaudeManagedAccountId - ) + const effectiveTarget = this.resolveWslDefaultTarget(target) + const normalizedTarget = normalizeClaudeAccountSelectionTarget(effectiveTarget) + const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) + const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) const previousAccount = this.getActiveAccount( settings.claudeManagedAccounts, this.lastSyncedAccountId @@ -163,8 +185,20 @@ export class ClaudeRuntimeAuthService { } } if (!activeAccount) { - if (settings.activeClaudeManagedAccountId) { - this.store.updateSettings({ activeClaudeManagedAccountId: null }) + if (activeAccountId) { + const nextSelection = setSelectedClaudeAccountIdForTarget( + normalizeClaudeRuntimeSelection(settings), + null, + normalizedTarget + ) + this.store.updateSettings({ + activeClaudeManagedAccountId: + normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + } + if (normalizedTarget.runtime === 'wsl') { + return } if (this.lastSyncedAccountId !== null) { await (previousAccount @@ -178,6 +212,47 @@ export class ClaudeRuntimeAuthService { return } + if (activeAccount.managedAuthRuntime === 'wsl') { + if (!this.getOwnedManagedAuthPath(activeAccount)) { + console.warn( + '[claude-runtime-auth] Active WSL managed account is not owned by Orca, restoring system default' + ) + const nextSelection = setSelectedClaudeAccountIdForTarget( + normalizeClaudeRuntimeSelection(settings), + null, + normalizedTarget + ) + this.store.updateSettings({ + activeClaudeManagedAccountId: + normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + return + } + const credentialsJson = await this.readManagedCredentials(activeAccount) + if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { + console.warn( + '[claude-runtime-auth] Active WSL managed account is missing or has invalid credentials, restoring system default' + ) + const nextSelection = setSelectedClaudeAccountIdForTarget( + normalizeClaudeRuntimeSelection(settings), + null, + normalizedTarget + ) + this.store.updateSettings({ + activeClaudeManagedAccountId: + normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + return + } + // Why: WSL managed Claude accounts are already isolated by their Linux + // CLAUDE_CONFIG_DIR. Materializing them into Windows ~/.claude would mix + // two runtime auth stores and break the Terminal-default runtime contract. + this.clearLastWrittenRuntimeState() + return + } + if (!this.getOwnedManagedAuthPath(activeAccount)) { console.warn( '[claude-runtime-auth] Active managed account is not owned by Orca, restoring system default' @@ -446,15 +521,74 @@ export class ClaudeRuntimeAuthService { return candidates } - private getPreparation(): ClaudeRuntimeAuthPreparation { + private getPreparation(target?: ClaudeAccountSelectionTarget): ClaudeRuntimeAuthPreparation { const settings = this.store.getSettings() const paths = this.pathResolver.getRuntimePaths() - const activeAccountId = settings.activeClaudeManagedAccountId + const normalizedTarget = this.resolveWslDefaultTarget( + target ?? + (process.platform === 'win32' && settings.terminalWindowsShell === 'wsl.exe' + ? ({ + runtime: 'wsl', + wslDistro: settings.terminalWindowsWslDistro ?? null + } satisfies ClaudeAccountSelectionTarget) + : ({ runtime: 'host' } satisfies ClaudeAccountSelectionTarget)) + ) + const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) + const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) + if ( + normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl' && + activeAccount?.managedAuthRuntime === 'wsl' && + activeAccount.wslLinuxAuthPath + ) { + return { + configDir: activeAccount.managedAuthPath, + runtime: 'wsl', + wslDistro: activeAccount.wslDistro ?? null, + wslLinuxConfigDir: activeAccount.wslLinuxAuthPath, + envPatch: { CLAUDE_CONFIG_DIR: activeAccount.wslLinuxAuthPath }, + stripAuthEnv: true, + provenance: `managed:${activeAccount.id}:wsl:${activeAccount.wslDistro ?? ''}` + } + } + if (normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl') { + const distro = + normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? getDefaultWslDistro() + const wslHome = distro ? getWslHome(distro) : null + const wslHomeInfo = wslHome ? parseWslUncPath(wslHome) : null + if (distro && wslHome && wslHomeInfo) { + const windowsConfigDir = join(wslHome, '.claude') + const linuxConfigDir = `${wslHomeInfo.linuxPath.replace(/\/$/, '')}/.claude` + return { + configDir: windowsConfigDir, + runtime: 'wsl', + wslDistro: distro, + wslLinuxConfigDir: linuxConfigDir, + envPatch: {}, + stripAuthEnv: true, + provenance: `wsl:${distro}:system` + } + } + return { + configDir: paths.configDir, + runtime: 'wsl', + wslDistro: normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro, + wslLinuxConfigDir: null, + envPatch: {}, + stripAuthEnv: true, + provenance: `wsl:${normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? '__default__'}:system` + } + } return { configDir: paths.configDir, + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, envPatch: paths.envPatch, - stripAuthEnv: Boolean(activeAccountId), - provenance: activeAccountId ? `managed:${activeAccountId}` : 'system' + stripAuthEnv: Boolean(activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl'), + provenance: + activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl' + ? `managed:${activeAccountId}` + : 'system' } } @@ -468,6 +602,16 @@ export class ClaudeRuntimeAuthService { return accounts.find((account) => account.id === activeAccountId) ?? null } + private resolveWslDefaultTarget( + target?: ClaudeAccountSelectionTarget + ): ClaudeAccountSelectionTarget { + if (target?.runtime !== 'wsl' || target.wslDistro?.trim()) { + return target ?? { runtime: 'host' } + } + const defaultDistro = getDefaultWslDistro() + return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target + } + private async findManagedAccountForRuntimeCredentials( runtimeCredentialsJson: string ): Promise { @@ -773,6 +917,46 @@ export class ClaudeRuntimeAuthService { } private getOwnedManagedAuthPath(account: ClaudeManagedAccount): string | null { + const wslInfo = parseWslUncPath(account.managedAuthPath) + if (wslInfo) { + if ( + !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || + !wslInfo.linuxPath.endsWith('/auth') + ) { + return null + } + if (process.platform === 'win32') { + try { + const canonicalLinuxPath = execFileSync( + 'wsl.exe', + [ + '-d', + wslInfo.distro, + '--', + 'bash', + '-lc', + buildEncodedWslBashCommand( + [ + 'set -euo pipefail', + `candidate=${shellQuote(wslInfo.linuxPath)}`, + 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', + 'candidate_real=$(readlink -f -- "$candidate")', + 'managed_root_real=$(readlink -f -- "$managed_root")', + 'test -f "$candidate_real/.orca-managed-claude-auth"', + `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(account.id)}`, + 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' + ].join('\n') + ) + ], + { encoding: 'utf-8', timeout: 5000 } + ).trim() + return canonicalLinuxPath ? toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) : null + } catch { + return null + } + } + return existsSync(account.managedAuthPath) ? account.managedAuthPath : null + } return resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { adoptLegacyMarker: true }) diff --git a/src/main/claude-accounts/runtime-selection.test.ts b/src/main/claude-accounts/runtime-selection.test.ts new file mode 100644 index 00000000000..997da720de1 --- /dev/null +++ b/src/main/claude-accounts/runtime-selection.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import type { ClaudeManagedAccount, GlobalSettings } from '../../shared/types' +import { + getSelectedClaudeAccountIdForTarget, + pruneInvalidClaudeRuntimeSelection, + setSelectedClaudeAccountIdForTarget +} from './runtime-selection' + +function createSettings( + overrides: Partial< + Pick + > = {} +): Pick { + return { + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} }, + ...overrides + } +} + +function createAccount( + overrides: Partial & Pick +): ClaudeManagedAccount { + const { id, ...rest } = overrides + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/tmp/${id}`, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...rest + } +} + +describe('Claude runtime account selection', () => { + it('selects host and WSL accounts independently', () => { + const first = setSelectedClaudeAccountIdForTarget({ host: null, wsl: {} }, 'host-account', { + runtime: 'host' + }) + const next = setSelectedClaudeAccountIdForTarget(first, 'wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(next).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + }) + + it('resolves a WSL default target when exactly one WSL distro has a selection', () => { + const settings = createSettings({ + activeClaudeManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + } + }) + + expect(getSelectedClaudeAccountIdForTarget(settings, { runtime: 'wsl' })).toBe('wsl-account') + expect(getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' })).toBe('host-account') + }) + + it('clears WSL selections without clearing the host selection for a WSL default target', () => { + const next = setSelectedClaudeAccountIdForTarget( + { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account', Debian: 'other-wsl-account' } + }, + null, + { runtime: 'wsl' } + ) + + expect(next).toEqual({ + host: 'host-account', + wsl: { Ubuntu: null, Debian: null } + }) + }) + + it('drops selections whose account belongs to another runtime', () => { + const selection = pruneInvalidClaudeRuntimeSelection( + { + host: 'wsl-account', + wsl: { Ubuntu: 'host-account', Debian: 'missing-account' } + }, + [ + createAccount({ id: 'host-account' }), + createAccount({ + id: 'wsl-account', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu' + }) + ] + ) + + expect(selection).toEqual({ + host: null, + wsl: { Ubuntu: null, Debian: null } + }) + }) +}) diff --git a/src/main/claude-accounts/runtime-selection.ts b/src/main/claude-accounts/runtime-selection.ts new file mode 100644 index 00000000000..8f3a3a4397b --- /dev/null +++ b/src/main/claude-accounts/runtime-selection.ts @@ -0,0 +1,147 @@ +import type { + ClaudeManagedAccount, + ClaudeManagedAccountRuntimeSelection, + GlobalSettings +} from '../../shared/types' + +export type ClaudeAccountSelectionTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export type NormalizedClaudeAccountSelectionTarget = { + runtime: 'host' | 'wsl' + wslDistro: string | null +} + +export function normalizeClaudeAccountSelectionTarget( + target?: ClaudeAccountSelectionTarget | null +): NormalizedClaudeAccountSelectionTarget { + if (target?.runtime === 'wsl') { + return { + runtime: 'wsl', + wslDistro: normalizeWslDistro(target.wslDistro) + } + } + return { runtime: 'host', wslDistro: null } +} + +export function normalizeClaudeRuntimeSelection( + settings: Pick< + GlobalSettings, + 'activeClaudeManagedAccountId' | 'activeClaudeManagedAccountIdsByRuntime' + > +): ClaudeManagedAccountRuntimeSelection { + return { + host: + settings.activeClaudeManagedAccountIdsByRuntime?.host ?? + settings.activeClaudeManagedAccountId ?? + null, + wsl: { ...settings.activeClaudeManagedAccountIdsByRuntime?.wsl } + } +} + +export function getSelectedClaudeAccountIdForTarget( + settings: Pick< + GlobalSettings, + 'activeClaudeManagedAccountId' | 'activeClaudeManagedAccountIdsByRuntime' + >, + target?: ClaudeAccountSelectionTarget | null +): string | null { + const selection = normalizeClaudeRuntimeSelection(settings) + const normalizedTarget = normalizeClaudeAccountSelectionTarget(target) + if (normalizedTarget.runtime === 'host') { + return selection.host + } + if (normalizedTarget.wslDistro) { + return selection.wsl[getClaudeWslSelectionKey(normalizedTarget.wslDistro)] ?? null + } + const selectedIds = Array.from(new Set(Object.values(selection.wsl).filter(Boolean))) + return ( + selection.wsl[getClaudeWslSelectionKey(null)] ?? + (selectedIds.length === 1 ? selectedIds[0] : null) + ) +} + +export function setSelectedClaudeAccountIdForTarget( + selection: ClaudeManagedAccountRuntimeSelection, + accountId: string | null, + target?: ClaudeAccountSelectionTarget | null +): ClaudeManagedAccountRuntimeSelection { + const normalizedTarget = normalizeClaudeAccountSelectionTarget(target) + if (normalizedTarget.runtime === 'host') { + return { host: accountId, wsl: { ...selection.wsl } } + } + if (accountId === null && normalizedTarget.wslDistro === null) { + return { + host: selection.host, + wsl: Object.fromEntries(Object.keys(selection.wsl).map((key) => [key, null])) + } + } + return { + host: selection.host, + wsl: { + ...selection.wsl, + [getClaudeWslSelectionKey(normalizedTarget.wslDistro)]: accountId + } + } +} + +export function removeClaudeAccountIdFromSelection( + selection: ClaudeManagedAccountRuntimeSelection, + accountId: string +): ClaudeManagedAccountRuntimeSelection { + const nextWsl: Record = {} + for (const [distro, selectedId] of Object.entries(selection.wsl)) { + nextWsl[distro] = selectedId === accountId ? null : selectedId + } + return { + host: selection.host === accountId ? null : selection.host, + wsl: nextWsl + } +} + +export function pruneInvalidClaudeRuntimeSelection( + selection: ClaudeManagedAccountRuntimeSelection, + accounts: ClaudeManagedAccount[] +): ClaudeManagedAccountRuntimeSelection { + const hostAccount = selection.host + ? accounts.find((account) => account.id === selection.host) + : null + const nextWsl: Record = {} + for (const [distroKey, accountId] of Object.entries(selection.wsl)) { + if (!accountId) { + nextWsl[distroKey] = null + continue + } + const account = accounts.find((entry) => entry.id === accountId) + nextWsl[distroKey] = + account && + account.managedAuthRuntime === 'wsl' && + getClaudeWslSelectionKey(account.wslDistro) === distroKey + ? accountId + : null + } + return { + host: hostAccount && hostAccount.managedAuthRuntime !== 'wsl' ? selection.host : null, + wsl: nextWsl + } +} + +export function getClaudeSelectionTargetForAccount( + account: ClaudeManagedAccount +): ClaudeAccountSelectionTarget { + if (account.managedAuthRuntime === 'wsl') { + return { runtime: 'wsl', wslDistro: account.wslDistro ?? null } + } + return { runtime: 'host' } +} + +export function getClaudeWslSelectionKey(wslDistro: string | null | undefined): string { + return normalizeWslDistro(wslDistro) ?? '__default__' +} + +function normalizeWslDistro(wslDistro: string | null | undefined): string | null { + const trimmed = wslDistro?.trim() + return trimmed ? trimmed : null +} diff --git a/src/main/claude-accounts/service.test.ts b/src/main/claude-accounts/service.test.ts index aa449677843..423b6aa9992 100644 --- a/src/main/claude-accounts/service.test.ts +++ b/src/main/claude-accounts/service.test.ts @@ -505,7 +505,9 @@ describe('ClaudeAccountService credential capture', () => { await service.removeAccount('account-1') expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith() + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('account-1', { + runtime: 'host' + }) expect(settings).toMatchObject({ claudeManagedAccounts: [], activeClaudeManagedAccountId: null @@ -576,7 +578,229 @@ describe('ClaudeAccountService credential capture', () => { await service.reauthenticateAccount('account-1') expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith() + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(undefined, { + runtime: 'host' + }) expect(settings.claudeManagedAccounts[0].email).toBe('new@example.com') }) + + it('selects a WSL account without changing the Windows active account', async () => { + setPlatform('linux') + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') + const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') + mkdirSync(hostAuthPath, { recursive: true }) + mkdirSync(wslAuthPath, { recursive: true }) + let settings = { + claudeManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedAuthPath: hostAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedAuthPath: wslAuthPath, + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'host-account', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + const snapshot = await service.selectAccountForTarget('wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(settings.activeClaudeManagedAccountId).toBe('host-account') + expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + expect(snapshot.activeAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + expect(runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(null, { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + }) + + it('rejects selecting a WSL account for the Windows target', async () => { + setPlatform('linux') + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') + mkdirSync(wslAuthPath, { recursive: true }) + const settings = { + claudeManagedAccounts: [ + { + id: 'wsl-account', + email: 'wsl@example.com', + managedAuthPath: wslAuthPath, + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn() + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + await expect( + service.selectAccountForTarget('wsl-account', { runtime: 'host' }) + ).rejects.toThrow('different runtime') + expect(runtimeAuth.syncForCurrentSelection).not.toHaveBeenCalled() + expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() + }) + + it('removes a WSL account without clearing the Windows active account', async () => { + setPlatform('linux') + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') + const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') + mkdirSync(hostAuthPath, { recursive: true }) + mkdirSync(wslAuthPath, { recursive: true }) + writeFileSync(join(wslAuthPath, '.orca-managed-claude-auth'), 'wsl-account\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedAuthPath: hostAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedAuthPath: wslAuthPath, + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'host-account', + activeClaudeManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + await service.removeAccount('wsl-account') + + expect(settings.activeClaudeManagedAccountId).toBe('host-account') + expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: null } + }) + expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('wsl-account') + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + }) }) diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 688af804f23..c09183c94fb 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: Claude managed accounts need one audited owner for login, credential capture, Keychain storage, selection, and rate-limit refresh. */ import { randomUUID } from 'node:crypto' -import { spawn } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, relative, resolve, sep } from 'node:path' @@ -30,6 +30,19 @@ import { writeManagedClaudeKeychainCredentials } from './keychain' import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' +import { buildEncodedWslBashCommand } from '../wsl-bash-command' +import { + getClaudeSelectionTargetForAccount, + getSelectedClaudeAccountIdForTarget, + normalizeClaudeAccountSelectionTarget, + normalizeClaudeRuntimeSelection, + pruneInvalidClaudeRuntimeSelection, + removeClaudeAccountIdFromSelection, + setSelectedClaudeAccountIdForTarget, + type ClaudeAccountSelectionTarget +} from './runtime-selection' const LOGIN_TIMEOUT_MS = 180_000 const STATUS_TIMEOUT_MS = 20_000 @@ -52,6 +65,22 @@ type ManagedClaudeAuthSnapshot = { oauthAccountJson: string | null } +export type ClaudeAccountAddTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +type ManagedClaudeAuthLocation = { + managedAuthPath: string + managedAuthRuntime: 'host' | 'wsl' + wslDistro: string | null + wslLinuxAuthPath: string | null +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + export class ClaudeAccountService { private mutationQueue: Promise = Promise.resolve() @@ -66,8 +95,8 @@ export class ClaudeAccountService { return this.getSnapshot() } - async addAccount(): Promise { - return this.serializeMutation(() => this.doAddAccount()) + async addAccount(target?: ClaudeAccountAddTarget): Promise { + return this.serializeMutation(() => this.doAddAccount(target)) } async reauthenticateAccount(accountId: string): Promise { @@ -82,19 +111,29 @@ export class ClaudeAccountService { return this.serializeMutation(() => this.doSelectAccount(accountId)) } + async selectAccountForTarget( + accountId: string | null, + target?: ClaudeAccountSelectionTarget + ): Promise { + return this.serializeMutation(() => this.doSelectAccount(accountId, target)) + } + private serializeMutation(fn: () => Promise): Promise { const next = this.mutationQueue.then(fn, fn) this.mutationQueue = next.catch(() => {}) return next } - private async doAddAccount(): Promise { + private async doAddAccount( + target?: ClaudeAccountAddTarget + ): Promise { const accountId = randomUUID() - const managedAuthPath = this.createManagedAuthDir(accountId) + const managedAuth = this.createManagedAuthDir(accountId, target) + const { managedAuthPath } = managedAuth const previousSettings = this.store.getSettings() try { - const captured = await this.runClaudeLoginAndCapture() + const captured = await this.runClaudeLoginAndCapture(managedAuth) if (!captured.identity.email) { throw new Error('Claude login completed, but Orca could not resolve the account email.') } @@ -105,6 +144,9 @@ export class ClaudeAccountService { id: accountId, email: captured.identity.email, managedAuthPath, + managedAuthRuntime: managedAuth.managedAuthRuntime, + wslDistro: managedAuth.wslDistro, + wslLinuxAuthPath: managedAuth.wslLinuxAuthPath, authMethod: 'subscription-oauth', organizationUuid: captured.identity.organizationUuid, organizationName: captured.identity.organizationName, @@ -113,14 +155,25 @@ export class ClaudeAccountService { lastAuthenticatedAt: now } - const outgoingAccountId = previousSettings.activeClaudeManagedAccountId + const selection = normalizeClaudeRuntimeSelection(previousSettings) + const targetSelection = getClaudeSelectionTargetForAccount(account) + const outgoingAccountId = getSelectedClaudeAccountIdForTarget( + previousSettings, + targetSelection + ) this.store.updateSettings({ claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], - activeClaudeManagedAccountId: account.id + activeClaudeManagedAccountId: + targetSelection.runtime === 'host' ? account.id : selection.host, + activeClaudeManagedAccountIdsByRuntime: setSelectedClaudeAccountIdForTarget( + selection, + account.id, + targetSelection + ) }) this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) - await this.syncRuntimeAuthWithLivePtyGate() - await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId) + await this.syncRuntimeAuthWithLivePtyGate(targetSelection) + await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId, targetSelection) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(previousSettings) @@ -135,7 +188,12 @@ export class ClaudeAccountService { const managedAuthPath = this.assertManagedAuthPath(account.managedAuthPath, accountId) const previousSettings = this.store.getSettings() const previousManagedAuth = await this.readManagedAuthSnapshot(accountId, managedAuthPath) - const captured = await this.runClaudeLoginAndCapture() + const captured = await this.runClaudeLoginAndCapture({ + managedAuthPath, + managedAuthRuntime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, + wslLinuxAuthPath: account.wslLinuxAuthPath ?? null + }) if (!captured.identity.email) { throw new Error('Claude login completed, but Orca could not resolve the account email.') } @@ -162,8 +220,11 @@ export class ClaudeAccountService { this.store.updateSettings({ claudeManagedAccounts: reauthenticatedAccounts }) this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) this.rateLimits.evictInactiveClaudeCache(accountId) - await this.syncRuntimeAuthWithLivePtyGate() - await this.rateLimits.refreshForClaudeAccountChange() + await this.syncRuntimeAuthWithLivePtyGate(getClaudeSelectionTargetForAccount(account)) + await this.rateLimits.refreshForClaudeAccountChange( + undefined, + getClaudeSelectionTargetForAccount(account) + ) return this.getSnapshot() } catch (error) { let restoredManagedCredentials = false @@ -206,26 +267,45 @@ export class ClaudeAccountService { const account = this.requireAccount(accountId) const settings = this.store.getSettings() const nextAccounts = settings.claudeManagedAccounts.filter((entry) => entry.id !== accountId) + const nextSelection = removeClaudeAccountIdFromSelection( + normalizeClaudeRuntimeSelection(settings), + accountId + ) const nextActiveId = - settings.activeClaudeManagedAccountId === accountId - ? null - : settings.activeClaudeManagedAccountId + settings.activeClaudeManagedAccountId === accountId ? null : nextSelection.host try { - if (settings.activeClaudeManagedAccountId === accountId) { - this.store.updateSettings({ activeClaudeManagedAccountId: null }) - await this.syncRuntimeAuthWithLivePtyGate() + if ( + getSelectedClaudeAccountIdForTarget( + settings, + getClaudeSelectionTargetForAccount(account) + ) === accountId + ) { + this.store.updateSettings({ + activeClaudeManagedAccountId: nextActiveId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + await this.syncRuntimeAuthWithLivePtyGate(getClaudeSelectionTargetForAccount(account)) this.store.updateSettings({ claudeManagedAccounts: nextAccounts }) } else { this.store.updateSettings({ claudeManagedAccounts: nextAccounts, - activeClaudeManagedAccountId: nextActiveId + activeClaudeManagedAccountId: nextActiveId, + activeClaudeManagedAccountIdsByRuntime: nextSelection }) - await this.syncRuntimeAuthWithLivePtyGate() + await this.syncRuntimeAuthWithLivePtyGate(getClaudeSelectionTargetForAccount(account)) } await this.safeRemoveManagedAuth(accountId, account.managedAuthPath) this.rateLimits.evictInactiveClaudeCache(accountId) - await this.rateLimits.refreshForClaudeAccountChange() + await this.rateLimits.refreshForClaudeAccountChange( + getSelectedClaudeAccountIdForTarget( + settings, + getClaudeSelectionTargetForAccount(account) + ) === accountId + ? accountId + : undefined, + getClaudeSelectionTargetForAccount(account) + ) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(settings) @@ -234,16 +314,37 @@ export class ClaudeAccountService { } } - private async doSelectAccount(accountId: string | null): Promise { + private async doSelectAccount( + accountId: string | null, + target?: ClaudeAccountSelectionTarget + ): Promise { + let effectiveTarget = target if (accountId !== null) { - this.requireAccount(accountId) + const account = this.requireAccount(accountId) + const accountTarget = getClaudeSelectionTargetForAccount(account) + const requestedTarget = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) + const normalizedAccountTarget = normalizeClaudeAccountSelectionTarget(accountTarget) + if ( + requestedTarget.runtime !== normalizedAccountTarget.runtime || + (requestedTarget.wslDistro !== null && + requestedTarget.wslDistro !== normalizedAccountTarget.wslDistro) + ) { + throw new Error('That Claude account belongs to a different runtime.') + } + effectiveTarget = accountTarget } const previousSettings = this.store.getSettings() - const outgoingAccountId = previousSettings.activeClaudeManagedAccountId - this.store.updateSettings({ activeClaudeManagedAccountId: accountId }) + const selection = normalizeClaudeRuntimeSelection(previousSettings) + const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previousSettings, effectiveTarget) + const nextSelection = setSelectedClaudeAccountIdForTarget(selection, accountId, effectiveTarget) + this.store.updateSettings({ + activeClaudeManagedAccountId: + effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) try { - await this.syncRuntimeAuthWithLivePtyGate() - await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId) + await this.syncRuntimeAuthWithLivePtyGate(effectiveTarget) + await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId, effectiveTarget) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(previousSettings) @@ -258,7 +359,8 @@ export class ClaudeAccountService { accounts: settings.claudeManagedAccounts .map((account) => this.toSummary(account)) .sort((a, b) => b.updatedAt - a.updatedAt), - activeAccountId: settings.activeClaudeManagedAccountId + activeAccountId: normalizeClaudeRuntimeSelection(settings).host, + activeAccountIdsByRuntime: normalizeClaudeRuntimeSelection(settings) } } @@ -266,6 +368,8 @@ export class ClaudeAccountService { return { id: account.id, email: account.email, + managedAuthRuntime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, authMethod: account.authMethod ?? 'unknown', organizationUuid: account.organizationUuid ?? null, organizationName: account.organizationName ?? null, @@ -287,54 +391,73 @@ export class ClaudeAccountService { private normalizeActiveSelection(): void { const settings = this.store.getSettings() - if (!settings.activeClaudeManagedAccountId) { - return - } - const hasActiveAccount = settings.claudeManagedAccounts.some( - (entry) => entry.id === settings.activeClaudeManagedAccountId + const nextSelection = pruneInvalidClaudeRuntimeSelection( + normalizeClaudeRuntimeSelection(settings), + settings.claudeManagedAccounts ) - if (!hasActiveAccount) { - this.store.updateSettings({ activeClaudeManagedAccountId: null }) + if ( + nextSelection.host !== settings.activeClaudeManagedAccountId || + JSON.stringify(nextSelection) !== JSON.stringify(normalizeClaudeRuntimeSelection(settings)) + ) { + this.store.updateSettings({ + activeClaudeManagedAccountId: nextSelection.host, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) } } private restoreClaudeSettings(settings: ReturnType): void { this.store.updateSettings({ claudeManagedAccounts: settings.claudeManagedAccounts, - activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId + activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: settings.activeClaudeManagedAccountIdsByRuntime }) } - private async syncRuntimeAuthWithLivePtyGate(operation?: () => Promise): Promise { + private async syncRuntimeAuthWithLivePtyGate( + target?: ClaudeAccountSelectionTarget, + operation?: () => Promise + ): Promise { beginClaudeAuthSwitch() try { - await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection()) + await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection(target)) } finally { endClaudeAuthSwitch() } } - private async runClaudeLoginAndCapture(): Promise { - const tempConfigDir = mkdtempSync(join(tmpdir(), 'orca-claude-login-')) + private async runClaudeLoginAndCapture( + location: ManagedClaudeAuthLocation = { + managedAuthPath: '', + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null + } + ): Promise { + const tempConfig = this.createTemporaryClaudeConfigDir(location) const previousLegacyKeychain = await readActiveClaudeKeychainCredentials() let captured: CapturedClaudeAuth | null = null let captureError: unknown = null let cleanupError: unknown = null try { - await this.runClaudeCommand(['auth', 'login', '--claudeai'], tempConfigDir, LOGIN_TIMEOUT_MS) + await this.runClaudeCommand(['auth', 'login', '--claudeai'], tempConfig, LOGIN_TIMEOUT_MS) const status = await this.runClaudeCommand( ['auth', 'status', '--json'], - tempConfigDir, + tempConfig, STATUS_TIMEOUT_MS, { allowFailure: true } ) - captured = await this.captureAuthFromConfigDir(tempConfigDir, status, previousLegacyKeychain) + captured = await this.captureAuthFromConfigDir( + tempConfig.windowsPath, + status, + previousLegacyKeychain + ) } catch (error) { captureError = error } finally { if (process.platform === 'darwin') { try { - await deleteActiveClaudeKeychainCredentialsStrict(tempConfigDir) + await deleteActiveClaudeKeychainCredentialsStrict(tempConfig.windowsPath) } catch (error) { console.warn('[claude-accounts] Failed to clean temporary Claude Keychain item:', error) } @@ -350,7 +473,7 @@ export class ClaudeAccountService { cleanupError = error } } - rmSync(tempConfigDir, { recursive: true, force: true }) + this.removeTemporaryClaudeConfigDir(tempConfig) } if (captureError) { throw captureError @@ -361,6 +484,72 @@ export class ClaudeAccountService { return captured! } + private createTemporaryClaudeConfigDir(location: ManagedClaudeAuthLocation): { + windowsPath: string + linuxPath: string | null + wslDistro: string | null + } { + if (location.managedAuthRuntime !== 'wsl') { + return { + windowsPath: mkdtempSync(join(tmpdir(), 'orca-claude-login-')), + linuxPath: null, + wslDistro: null + } + } + if (!location.wslDistro) { + throw new Error('Could not resolve the active WSL distribution for Claude login.') + } + const linuxPath = execFileSync( + 'wsl.exe', + [ + '-d', + location.wslDistro, + '--', + 'bash', + '-lc', + 'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"' + ], + { encoding: 'utf-8', timeout: 5000 } + ) + .replaceAll(String.fromCharCode(0), '') + .trim() + if (!linuxPath.startsWith('/')) { + throw new Error('Could not create a temporary WSL Claude login directory.') + } + return { + windowsPath: toWindowsWslPath(linuxPath, location.wslDistro), + linuxPath, + wslDistro: location.wslDistro + } + } + + private removeTemporaryClaudeConfigDir(tempConfig: { + windowsPath: string + linuxPath: string | null + wslDistro: string | null + }): void { + if (tempConfig.linuxPath && tempConfig.wslDistro) { + try { + execFileSync( + 'wsl.exe', + [ + '-d', + tempConfig.wslDistro, + '--', + 'bash', + '-lc', + `rm -rf -- ${shellQuote(tempConfig.linuxPath)}` + ], + { encoding: 'utf-8', timeout: 5000 } + ) + } catch { + // Best-effort cleanup. + } + return + } + rmSync(tempConfig.windowsPath, { recursive: true, force: true }) + } + private async captureAuthFromConfigDir( configDir: string, statusOutput: string, @@ -520,11 +709,72 @@ export class ClaudeAccountService { } } - private createManagedAuthDir(accountId: string): string { + private createManagedAuthDir( + accountId: string, + target?: ClaudeAccountAddTarget + ): ManagedClaudeAuthLocation { + const wslAuth = this.tryCreateWslManagedAuthDir(accountId, target) + if (wslAuth) { + return wslAuth + } + const managedAuthPath = join(this.getManagedAccountsRoot(), accountId, 'auth') mkdirSync(managedAuthPath, { recursive: true }) writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, 'utf-8') - return this.assertManagedAuthPath(managedAuthPath, accountId) + return { + managedAuthPath: this.assertManagedAuthPath(managedAuthPath, accountId), + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null + } + } + + private tryCreateWslManagedAuthDir( + accountId: string, + target?: ClaudeAccountAddTarget + ): ManagedClaudeAuthLocation | null { + if (process.platform !== 'win32' || target?.runtime !== 'wsl') { + return null + } + + const distroArgs = target.wslDistro?.trim() ? ['-d', target.wslDistro.trim()] : [] + const infoOutput = execFileSync( + 'wsl.exe', + [...distroArgs, '--', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'], + { encoding: 'utf-8', timeout: 5000 } + ) + const [rawDistro, rawHome] = infoOutput + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim()) + const distro = target.wslDistro?.trim() || rawDistro + const home = rawHome + if (!distro || !home?.startsWith('/')) { + throw new Error('Could not resolve the active WSL home directory for Claude login.') + } + + const wslLinuxAuthPath = `${home.replace(/\/$/, '')}/.local/share/orca/claude-accounts/${accountId}/auth` + const markerPath = `${wslLinuxAuthPath}/.orca-managed-claude-auth` + execFileSync( + 'wsl.exe', + [ + '-d', + distro, + '--', + 'bash', + '-lc', + `mkdir -p ${shellQuote(wslLinuxAuthPath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}` + ], + { encoding: 'utf-8', timeout: 5000 } + ) + + const managedAuthPath = toWindowsWslPath(wslLinuxAuthPath, distro) + return { + managedAuthPath: this.assertManagedAuthPath(managedAuthPath, accountId), + managedAuthRuntime: 'wsl', + wslDistro: distro, + wslLinuxAuthPath + } } private getManagedAccountsRoot(): string { @@ -534,6 +784,60 @@ export class ClaudeAccountService { } private assertManagedAuthPath(candidatePath: string, expectedAccountId?: string): string { + const wslInfo = parseWslUncPath(candidatePath) + if (wslInfo) { + if ( + !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || + !wslInfo.linuxPath.endsWith('/auth') + ) { + throw new Error('Managed WSL Claude auth storage is outside Orca account storage.') + } + if (process.platform === 'win32') { + try { + const canonicalLinuxPath = execFileSync( + 'wsl.exe', + [ + '-d', + wslInfo.distro, + '--', + 'bash', + '-lc', + buildEncodedWslBashCommand( + [ + 'set -euo pipefail', + `candidate=${shellQuote(wslInfo.linuxPath)}`, + 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', + 'candidate_real=$(readlink -f -- "$candidate")', + 'managed_root_real=$(readlink -f -- "$managed_root")', + 'test -f "$candidate_real/.orca-managed-claude-auth"', + expectedAccountId + ? `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(expectedAccountId)}` + : 'test -n "$(cat "$candidate_real/.orca-managed-claude-auth")"', + 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' + ].join('\n') + ) + ], + { encoding: 'utf-8', timeout: 5000 } + ).trim() + if (!canonicalLinuxPath) { + throw new Error('Managed Claude auth directory does not exist on disk.') + } + return toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) + } catch (error) { + throw new Error('Managed WSL Claude auth storage is outside Orca account storage.', { + cause: error + }) + } + } + if ( + !existsSync(candidatePath) || + !existsSync(join(candidatePath, '.orca-managed-claude-auth')) + ) { + throw new Error('Managed Claude auth storage is not owned by Orca.') + } + return candidatePath + } + this.getManagedAccountsRoot() const accountId = expectedAccountId ?? this.readManagedAuthAccountIdFromPath(candidatePath) if (!accountId || (expectedAccountId && accountId !== expectedAccountId)) { @@ -567,19 +871,39 @@ export class ClaudeAccountService { private runClaudeCommand( args: string[], - configDir: string, + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, timeoutMs: number, options?: { allowFailure?: boolean } ): Promise { return new Promise((resolvePromise, rejectPromise) => { - const claudeCommand = resolveClaudeCommand() - const child = spawn(claudeCommand, args, { + const spawnConfig = + configDir.linuxPath && configDir.wslDistro + ? { + command: 'wsl.exe', + args: [ + '-d', + configDir.wslDistro, + '--', + 'bash', + '-lc', + `export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}` + ], + env: process.env, + shell: false + } + : { + command: resolveClaudeCommand(), + args, + env: { + ...process.env, + CLAUDE_CONFIG_DIR: configDir.windowsPath + }, + shell: process.platform === 'win32' + } + const child = spawn(spawnConfig.command, spawnConfig.args, { stdio: ['ignore', 'pipe', 'pipe'], - shell: process.platform === 'win32', - env: { - ...process.env, - CLAUDE_CONFIG_DIR: configDir - } + shell: spawnConfig.shell, + env: spawnConfig.env }) let settled = false diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index c59b6f1624d..cb0d0084726 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -74,6 +74,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalAllowOsc52Clipboard: false, setupScriptLaunchMode: 'split-vertical', terminalScrollbackBytes: 10_000_000, + localAccountRuntime: 'host', + localAccountWslDistro: null, openLinksInApp: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', @@ -479,7 +481,9 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) expect(existsSync(runtimeAuthPath)).toBe(false) expect(warnSpy).toHaveBeenCalled() }) @@ -517,7 +521,9 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) expect(warnSpy).toHaveBeenCalled() }) @@ -533,7 +539,9 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) expect(existsSync(runtimeAuthPath)).toBe(false) }) @@ -673,6 +681,651 @@ describe('CodexRuntimeHomeService', () => { ) }) + it('does not touch host auth on startup when the active account is WSL-backed', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"host-system"}\n', 'utf-8') + const wslManagedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"wsl"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + const store = createStore(settings) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"host-system"}\n') + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe( + '{"account":"wsl"}\n' + ) + expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('clears a selected WSL managed account when auth.json is missing', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed-token') + ) + rmSync(join(managedHomePath, 'auth.json'), { force: true }) + const systemCodexHomePath = join(wslHome, '.codex') + mkdirSync(systemCodexHomePath, { recursive: true }) + writeFileSync(join(systemCodexHomePath, 'auth.json'), systemAuth, 'utf-8') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + expect(store.updateSettings).toHaveBeenCalledWith({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('switches WSL accounts by rewriting one stable WSL runtime home', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const firstAuth = createCodexAuthJson('first@example.com', 'acct-first', 'first-token') + const secondAuth = createCodexAuthJson('second@example.com', 'acct-second', 'second-token') + const firstManagedHomePath = createManagedAuth(testState.userDataDir, 'account-1', firstAuth) + const secondManagedHomePath = createManagedAuth(testState.userDataDir, 'account-2', secondAuth) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'first@example.com', + managedHomePath: firstManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: 'acct-first', + workspaceLabel: null, + workspaceAccountId: 'acct-first', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'second@example.com', + managedHomePath: secondManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-2/home', + providerAccountId: 'acct-second', + workspaceLabel: null, + workspaceAccountId: 'acct-second', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + + store.updateSettings({ + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-2' } } + }) + service.syncForCurrentSelection(target) + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('does not use host auth baseline to accept stale WSL runtime auth', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const hostAuth = createCodexAuthJson('host@example.com', 'acct-host', 'host-token') + const wslManagedAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'managed-newer', + 2_000 + ) + const staleWslRuntimeAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'runtime-stale', + 1_000 + ) + const hostManagedHomePath = createManagedAuth(testState.userDataDir, 'host-account', hostAuth) + const wslManagedHomePath = createManagedAuth( + testState.userDataDir, + 'wsl-account', + wslManagedAuth + ) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + mkdirSync(wslRuntimeHomePath, { recursive: true }) + writeFileSync(join(wslRuntimeHomePath, 'auth.json'), staleWslRuntimeAuth, 'utf-8') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedHomePath: hostManagedHomePath, + providerAccountId: 'acct-host', + workspaceLabel: null, + workspaceAccountId: 'acct-host', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/wsl-account/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'host-account', + activeCodexManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(hostAuth) + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + expect(readFileSync(join(wslManagedHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('does not clobber fresh WSL tokens after clearLastWrittenAuthJson', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const originalAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'original', 1_000) + const staleRuntimeAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'stale', 1_500) + const reauthedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'reauthed', 2_000) + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'account-1' } + } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') + writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') + + service.clearLastWrittenAuthJson('account-1') + service.syncForCurrentSelection(target) + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('uses the stable WSL runtime home for WSL system-default rate-limit fetches', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + ) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('uses the default distro selection for WSL-default rate-limit fetches', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const ubuntuAuth = createCodexAuthJson('ubuntu@example.com', 'acct-ubuntu', 'ubuntu-token') + const debianAuth = createCodexAuthJson('debian@example.com', 'acct-debian', 'debian-token') + const ubuntuHomePath = createManagedAuth(testState.userDataDir, 'ubuntu-account', ubuntuAuth) + const debianHomePath = createManagedAuth(testState.userDataDir, 'debian-account', debianAuth) + const runtimeAuthPath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home', + 'auth.json' + ) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'ubuntu-account', + email: 'ubuntu@example.com', + managedHomePath: ubuntuHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/ubuntu/home', + providerAccountId: 'acct-ubuntu', + workspaceLabel: null, + workspaceAccountId: 'acct-ubuntu', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'debian-account', + email: 'debian@example.com', + managedHomePath: debianHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Debian', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/debian/home', + providerAccountId: 'acct-debian', + workspaceLabel: null, + workspaceAccountId: 'acct-debian', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'ubuntu-account', Debian: 'debian-account' } + } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toBe( + join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + ) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('does not write WSL system-default auth into managed accounts', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const managedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed-old', 1_000) + const systemDefaultAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'system-newer', + 2_000 + ) + const managedHomePath = createManagedAuth(testState.userDataDir, 'wsl-account', managedAuth) + const systemCodexHomePath = join(wslHome, '.codex') + mkdirSync(systemCodexHomePath, { recursive: true }) + writeFileSync(join(systemCodexHomePath, 'auth.json'), systemDefaultAuth, 'utf-8') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'wsl-account', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/wsl-account/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemDefaultAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('reads WSL system-default token refreshes back to WSL system auth', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const systemAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'system-old', 1_000) + const refreshedAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'runtime-refreshed', + 2_000 + ) + const systemCodexHomePath = join(wslHome, '.codex') + mkdirSync(systemCodexHomePath, { recursive: true }) + writeFileSync(join(systemCodexHomePath, 'auth.json'), systemAuth, 'utf-8') + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8') + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('preserves WSL system-default token refreshes after app restart', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const systemAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'system-old', 1_000) + const refreshedAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'runtime-refreshed', + 2_000 + ) + const systemCodexHomePath = join(wslHome, '.codex') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + mkdirSync(systemCodexHomePath, { recursive: true }) + mkdirSync(wslRuntimeHomePath, { recursive: true }) + writeFileSync(join(systemCodexHomePath, 'auth.json'), systemAuth, 'utf-8') + writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8') + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + it('does not overwrite auth.json when no managed account was ever active', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"original"}\n', 'utf-8') @@ -998,7 +1651,9 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) expect(existsSync(runtimeAuthPath)).toBe(false) expect(warnSpy).toHaveBeenCalled() }) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 981e7aec70c..0be2ebef6e6 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -12,7 +12,7 @@ import { rmSync, statSync } from 'node:fs' -import { dirname, extname, join, parse, relative } from 'node:path' +import { dirname, extname, join, parse, relative, win32 as pathWin32 } from 'node:path' import { app } from 'electron' import type { CodexManagedAccount } from '../../shared/types' import type { Store } from '../persistence' @@ -24,6 +24,14 @@ import { } from '../codex/codex-home-paths' import { syncSystemCodexSessionsIntoManagedHome } from '../codex/codex-session-bridge' import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { + getSelectedCodexAccountIdForTarget, + normalizeCodexRuntimeSelection, + setSelectedCodexAccountIdForTarget, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { getDefaultWslDistro, getWslHome } from '../wsl' type CodexAuthIdentity = { email: string | null @@ -67,6 +75,11 @@ export class CodexRuntimeHomeService { // login (e.g. `codex auth login`) overwrote it — so Orca adopts the file as // the new system default instead of restoring a stale snapshot. private lastWrittenAuthJson: string | null = null + // Why: WSL terminals have their own stable runtime homes per distro. They + // cannot share the host baseline or host sync can make stale WSL auth look + // newer than managed storage. + private readonly lastWrittenWslAuthJsonByDistro = new Map() + private readonly lastSyncedWslAccountIdByDistro = new Map() private skipNextReadBackForAccountId: string | null = null constructor(private readonly store: Store) { @@ -77,10 +90,26 @@ export class CodexRuntimeHomeService { private initializeLastSyncedState(): void { const settings = this.store.getSettings() - this.lastSyncedAccountId = settings.activeCodexManagedAccountId + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + // Why: WSL-managed homes are never materialized into host ~/.codex. + // Treating one as "last synced" makes cold start look like a host-account + // transition and can restore/delete host auth that Orca never touched. + this.lastSyncedAccountId = this.getWslManagedHomePath(activeAccount) + ? null + : normalizeCodexRuntimeSelection(settings).host } - prepareForCodexLaunch(): string { + prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { + if (target?.runtime === 'wsl') { + const wslTarget = this.resolveWslDefaultTarget(target) + return ( + this.syncWslRuntimeForCurrentSelection(wslTarget) ?? + this.getWslSystemCodexHomePath(wslTarget) + ) + } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() @@ -88,14 +117,38 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } - prepareForRateLimitFetch(): string { + private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { + if (process.platform !== 'win32') { + return null + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return null + } + const home = getWslHome(distro) + return home ? this.joinWslPath(home, '.codex') : null + } + + prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): string | null { + if (target?.runtime === 'wsl') { + const wslTarget = this.resolveWslDefaultTarget(target) + return ( + this.syncWslRuntimeForCurrentSelection(wslTarget) ?? + this.getWslSystemCodexHomePath(wslTarget) + ) + } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() return this.getRuntimeHomePath() } - syncForCurrentSelection(): void { + syncForCurrentSelection(target?: CodexAccountSelectionTarget): void { + if (target?.runtime === 'wsl') { + this.syncWslRuntimeForCurrentSelection(target) + return + } + const settings = this.store.getSettings() const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) if (this.lastSyncedAccountId === null) { @@ -103,12 +156,29 @@ export class CodexRuntimeHomeService { } const activeAccount = this.getActiveAccount( settings.codexManagedAccounts, - settings.activeCodexManagedAccountId + normalizeCodexRuntimeSelection(settings).host ) const previousAccount = this.getActiveAccount( settings.codexManagedAccounts, this.lastSyncedAccountId ) + if (this.getWslManagedHomePath(activeAccount)) { + const previousWasHostManaged = previousAccount && !this.getWslManagedHomePath(previousAccount) + const outgoingReadBackResult = previousWasHostManaged + ? this.readBackRefreshedTokensForAccount(previousAccount, { + updateLastWrittenAuthJson: false + }) + : 'unchanged' + if (previousWasHostManaged) { + this.restoreSystemDefaultSnapshot({ + detectExternalLogin: outgoingReadBackResult !== 'rejected' + }) + } + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + this.skipNextReadBackForAccountId = null + return + } let outgoingReadBackResult: CodexReadBackResult = 'unchanged' if (previousAccount && previousAccount.id !== activeAccount?.id) { outgoingReadBackResult = this.readBackRefreshedTokensForAccount(previousAccount, { @@ -116,8 +186,14 @@ export class CodexRuntimeHomeService { }) } if (!activeAccount) { - if (settings.activeCodexManagedAccountId) { - this.store.updateSettings({ activeCodexManagedAccountId: null }) + if (normalizeCodexRuntimeSelection(settings).host) { + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) } // Why: only restore the system-default mirror when transitioning FROM a // managed account. When no managed account was ever active, later syncs @@ -164,7 +240,13 @@ export class CodexRuntimeHomeService { console.warn( '[codex-runtime-home] Active managed account is missing auth.json, restoring system default' ) - this.store.updateSettings({ activeCodexManagedAccountId: null }) + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) if (this.lastSyncedAccountId !== null) { this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) this.lastSyncedAccountId = null @@ -201,8 +283,10 @@ export class CodexRuntimeHomeService { // re-auth or add-account. Those flows write fresh tokens to managed storage, // so the read-back must be skipped to avoid overwriting them with stale // runtime tokens. - clearLastWrittenAuthJson(accountId = this.store.getSettings().activeCodexManagedAccountId): void { - if (accountId === this.store.getSettings().activeCodexManagedAccountId) { + clearLastWrittenAuthJson( + accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host + ): void { + if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { this.lastWrittenAuthJson = null } this.skipNextReadBackForAccountId = accountId @@ -211,18 +295,36 @@ export class CodexRuntimeHomeService { private readBackRefreshedTokens(options: { updateLastWrittenAuthJson: boolean }): CodexReadBackResult { + return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), options) + } + + private readBackRefreshedTokensFromPath( + runtimeAuthPath: string, + options: { + updateLastWrittenAuthJson: boolean + lastWrittenAuthJson?: string | null + setLastWrittenAuthJson?: (contents: string) => void + expectedAccountId?: string + } + ): CodexReadBackResult { try { - const runtimeAuthPath = this.getRuntimeAuthPath() if (!existsSync(runtimeAuthPath)) { return 'unchanged' } + const lastWrittenAuthJson = + options.lastWrittenAuthJson === undefined + ? this.lastWrittenAuthJson + : options.lastWrittenAuthJson const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') - if (this.lastWrittenAuthJson !== null && runtimeContents === this.lastWrittenAuthJson) { + if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) { return 'unchanged' } - const match = this.findManagedAccountForRuntimeAuth(runtimeContents) + const match = this.findManagedAccountForRuntimeAuth( + runtimeContents, + options.expectedAccountId + ) if (match.kind !== 'matched') { if (match.kind === 'ambiguous') { console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back') @@ -232,7 +334,7 @@ export class CodexRuntimeHomeService { // Why: after app restart, Orca has no last-written baseline. Identity // alone cannot prove runtime auth is newer than managed storage. if ( - this.lastWrittenAuthJson === null && + lastWrittenAuthJson === null && !this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents) ) { return 'rejected' @@ -240,7 +342,11 @@ export class CodexRuntimeHomeService { writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 }) if (options.updateLastWrittenAuthJson) { - this.lastWrittenAuthJson = runtimeContents + if (options.setLastWrittenAuthJson) { + options.setLastWrittenAuthJson(runtimeContents) + } else { + this.lastWrittenAuthJson = runtimeContents + } } return 'persisted' } catch (error) { @@ -253,10 +359,13 @@ export class CodexRuntimeHomeService { } private readBackRefreshedTokensForAccount( - _account: CodexManagedAccount, + account: CodexManagedAccount, options: { updateLastWrittenAuthJson: boolean } ): CodexReadBackResult { - return this.readBackRefreshedTokens(options) + return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), { + ...options, + expectedAccountId: account.id + }) } private safeSyncForCurrentSelection(): void { @@ -277,13 +386,184 @@ export class CodexRuntimeHomeService { return accounts.find((account) => account.id === activeAccountId) ?? null } - private findManagedAccountForRuntimeAuth(runtimeAuthContents: string): CodexReadBackMatch { + private getWslManagedHomePath(account: CodexManagedAccount | null): string | null { + if (!account) { + return null + } + if (account.managedHomeRuntime === 'wsl' && parseWslUncPath(account.managedHomePath)) { + return account.managedHomePath + } + return parseWslUncPath(account.managedHomePath) ? account.managedHomePath : null + } + + private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { + if (process.platform !== 'win32') { + return null + } + + const wslTarget = this.resolveWslDefaultTarget(target) + const settings = this.store.getSettings() + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + getSelectedCodexAccountIdForTarget(settings, wslTarget) + ) + const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro() + if (!distro) { + return null + } + + const runtimeHomePath = this.getWslRuntimeHomePath(distro) + if (!runtimeHomePath) { + return null + } + + mkdirSync(runtimeHomePath, { recursive: true }) + this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) + + const runtimeAuthPath = join(runtimeHomePath, 'auth.json') + const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null + if (previousWslAccountId) { + if (this.skipNextReadBackForAccountId === previousWslAccountId) { + this.skipNextReadBackForAccountId = null + } else { + const previousWslAccount = this.getActiveAccount( + settings.codexManagedAccounts, + previousWslAccountId + ) + if (previousWslAccount) { + this.readBackRefreshedTokensFromPath(runtimeAuthPath, { + updateLastWrittenAuthJson: true, + lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, + setLastWrittenAuthJson: (contents) => { + this.lastWrittenWslAuthJsonByDistro.set(distro, contents) + }, + expectedAccountId: previousWslAccount.id + }) + } + } + } + + const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null + if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) { + const activeAuth = readFileSync(activeAuthPath, 'utf-8') + this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) + return runtimeHomePath + } + if (activeAccount && activeAuthPath) { + console.warn( + '[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default' + ) + this.store.updateSettings({ + activeCodexManagedAccountId: settings.activeCodexManagedAccountId, + activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( + normalizeCodexRuntimeSelection(settings), + null, + wslTarget + ) + }) + } + + const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) + if (systemAuthPath && existsSync(systemAuthPath)) { + const systemAuth = readFileSync(systemAuthPath, 'utf-8') + const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null + const runtimeAuth = existsSync(runtimeAuthPath) + ? readFileSync(runtimeAuthPath, 'utf-8') + : null + if ( + runtimeAuth !== null && + runtimeAuth !== systemAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) && + ((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) || + (mirroredSystemDefaultAuth === null && + this.runtimeAuthIsFresher(runtimeAuth, systemAuth))) + ) { + // Why: WSL runtime homes are per-distro and their in-memory baseline is + // lost on app restart. A same-identity fresher runtime auth is a Codex + // token refresh and should be copied back before we mirror ~/.codex. + this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenWslAuthJsonByDistro.set(distro, null) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + + private getWslRuntimeHomePath(distro: string): string | null { + const home = getWslHome(distro) + return home + ? this.joinWslPath(home, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + : null + } + + private joinWslPath(basePath: string, ...segments: string[]): string { + return parseWslUncPath(basePath) + ? pathWin32.join(basePath, ...segments) + : join(basePath, ...segments) + } + + private resolveWslDefaultTarget( + target: CodexAccountSelectionTarget + ): CodexAccountSelectionTarget { + if (target.runtime !== 'wsl' || target.wslDistro?.trim()) { + return target + } + const defaultDistro = getDefaultWslDistro() + return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target + } + + private getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null { + const home = this.getWslSystemCodexHomePath(target) + return home ? this.joinWslPath(home, 'auth.json') : null + } + + private seedWslRuntimeHome( + runtimeHomePath: string, + activeAccount: CodexManagedAccount | null, + distro: string + ): void { + const runtimeConfigPath = join(runtimeHomePath, 'config.toml') + if (existsSync(runtimeConfigPath)) { + return + } + + const candidateHomes = [ + activeAccount?.managedHomePath, + this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + ].filter((value): value is string => Boolean(value)) + for (const homePath of candidateHomes) { + const configPath = join(homePath, 'config.toml') + if (existsSync(configPath)) { + copyFileSync(configPath, runtimeConfigPath) + return + } + } + } + + private findManagedAccountForRuntimeAuth( + runtimeAuthContents: string, + expectedAccountId?: string + ): CodexReadBackMatch { const matches: { account: CodexManagedAccount managedAuthPath: string managedAuthContents: string }[] = [] for (const account of this.store.getSettings().codexManagedAccounts) { + if (expectedAccountId && account.id !== expectedAccountId) { + continue + } const managedAuthPath = join(account.managedHomePath, 'auth.json') if (!existsSync(managedAuthPath)) { continue @@ -875,6 +1155,15 @@ export class CodexRuntimeHomeService { this.lastWrittenAuthJson = contents } + private writeRuntimeAuthAtPath(authPath: string, contents: string): void { + if (this.fileContentsEqual(authPath, contents)) { + this.ensureOwnerOnlyMode(authPath) + return + } + mkdirSync(dirname(authPath), { recursive: true }) + writeFileAtomically(authPath, contents, { mode: 0o600 }) + } + private fileContentsEqual(targetPath: string, contents: string): boolean { try { return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents diff --git a/src/main/codex-accounts/runtime-selection.test.ts b/src/main/codex-accounts/runtime-selection.test.ts new file mode 100644 index 00000000000..df98d20242b --- /dev/null +++ b/src/main/codex-accounts/runtime-selection.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' +import { + getSelectedCodexAccountIdForTarget, + pruneInvalidCodexRuntimeSelection, + setSelectedCodexAccountIdForTarget +} from './runtime-selection' + +function createSettings( + overrides: Partial< + Pick + > = {} +): Pick { + return { + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} }, + ...overrides + } +} + +function createAccount( + overrides: Partial & Pick +): CodexManagedAccount { + const { id, ...rest } = overrides + return { + id, + email: `${id}@example.com`, + managedHomePath: `/tmp/${id}`, + managedHomeRuntime: 'host', + wslDistro: null, + wslLinuxHomePath: null, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...rest + } +} + +describe('Codex runtime account selection', () => { + it('selects host and WSL accounts independently', () => { + const first = setSelectedCodexAccountIdForTarget({ host: null, wsl: {} }, 'host-account', { + runtime: 'host' + }) + const next = setSelectedCodexAccountIdForTarget(first, 'wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(next).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + }) + + it('resolves a WSL default target when exactly one WSL distro has a selection', () => { + const settings = createSettings({ + activeCodexManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + } + }) + + expect(getSelectedCodexAccountIdForTarget(settings, { runtime: 'wsl' })).toBe('wsl-account') + expect(getSelectedCodexAccountIdForTarget(settings, { runtime: 'host' })).toBe('host-account') + }) + + it('clears WSL selections without clearing the host selection for a WSL default target', () => { + const next = setSelectedCodexAccountIdForTarget( + { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account', Debian: 'other-wsl-account' } + }, + null, + { runtime: 'wsl' } + ) + + expect(next).toEqual({ + host: 'host-account', + wsl: { Ubuntu: null, Debian: null } + }) + }) + + it('drops selections whose account belongs to another runtime', () => { + const selection = pruneInvalidCodexRuntimeSelection( + { + host: 'wsl-account', + wsl: { Ubuntu: 'host-account', Debian: 'missing-account' } + }, + [ + createAccount({ id: 'host-account' }), + createAccount({ + id: 'wsl-account', + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu' + }) + ] + ) + + expect(selection).toEqual({ + host: null, + wsl: { Ubuntu: null, Debian: null } + }) + }) +}) diff --git a/src/main/codex-accounts/runtime-selection.ts b/src/main/codex-accounts/runtime-selection.ts new file mode 100644 index 00000000000..4b6ff3a5171 --- /dev/null +++ b/src/main/codex-accounts/runtime-selection.ts @@ -0,0 +1,146 @@ +import type { + CodexManagedAccount, + CodexManagedAccountRuntimeSelection, + GlobalSettings +} from '../../shared/types' + +export type CodexAccountSelectionTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export type NormalizedCodexAccountSelectionTarget = { + runtime: 'host' | 'wsl' + wslDistro: string | null +} + +export function normalizeCodexAccountSelectionTarget( + target?: CodexAccountSelectionTarget | null +): NormalizedCodexAccountSelectionTarget { + if (target?.runtime === 'wsl') { + return { + runtime: 'wsl', + wslDistro: normalizeWslDistro(target.wslDistro) + } + } + return { runtime: 'host', wslDistro: null } +} + +export function normalizeCodexRuntimeSelection( + settings: Pick< + GlobalSettings, + 'activeCodexManagedAccountId' | 'activeCodexManagedAccountIdsByRuntime' + > +): CodexManagedAccountRuntimeSelection { + return { + host: + settings.activeCodexManagedAccountIdsByRuntime?.host ?? + settings.activeCodexManagedAccountId ?? + null, + wsl: { ...settings.activeCodexManagedAccountIdsByRuntime?.wsl } + } +} + +export function getSelectedCodexAccountIdForTarget( + settings: Pick< + GlobalSettings, + 'activeCodexManagedAccountId' | 'activeCodexManagedAccountIdsByRuntime' + >, + target?: CodexAccountSelectionTarget | null +): string | null { + const selection = normalizeCodexRuntimeSelection(settings) + const normalizedTarget = normalizeCodexAccountSelectionTarget(target) + if (normalizedTarget.runtime === 'host') { + return selection.host + } + if (normalizedTarget.wslDistro) { + return selection.wsl[getWslSelectionKey(normalizedTarget.wslDistro)] ?? null + } + const selectedIds = Array.from(new Set(Object.values(selection.wsl).filter(Boolean))) + return ( + selection.wsl[getWslSelectionKey(null)] ?? (selectedIds.length === 1 ? selectedIds[0] : null) + ) +} + +export function setSelectedCodexAccountIdForTarget( + selection: CodexManagedAccountRuntimeSelection, + accountId: string | null, + target?: CodexAccountSelectionTarget | null +): CodexManagedAccountRuntimeSelection { + const normalizedTarget = normalizeCodexAccountSelectionTarget(target) + if (normalizedTarget.runtime === 'host') { + return { host: accountId, wsl: { ...selection.wsl } } + } + if (accountId === null && normalizedTarget.wslDistro === null) { + return { + host: selection.host, + wsl: Object.fromEntries(Object.keys(selection.wsl).map((key) => [key, null])) + } + } + return { + host: selection.host, + wsl: { + ...selection.wsl, + [getWslSelectionKey(normalizedTarget.wslDistro)]: accountId + } + } +} + +export function removeCodexAccountIdFromSelection( + selection: CodexManagedAccountRuntimeSelection, + accountId: string +): CodexManagedAccountRuntimeSelection { + const nextWsl: Record = {} + for (const [distro, selectedId] of Object.entries(selection.wsl)) { + nextWsl[distro] = selectedId === accountId ? null : selectedId + } + return { + host: selection.host === accountId ? null : selection.host, + wsl: nextWsl + } +} + +export function pruneInvalidCodexRuntimeSelection( + selection: CodexManagedAccountRuntimeSelection, + accounts: CodexManagedAccount[] +): CodexManagedAccountRuntimeSelection { + const hostAccount = selection.host + ? accounts.find((account) => account.id === selection.host) + : null + const nextWsl: Record = {} + for (const [distroKey, accountId] of Object.entries(selection.wsl)) { + if (!accountId) { + nextWsl[distroKey] = null + continue + } + const account = accounts.find((entry) => entry.id === accountId) + nextWsl[distroKey] = + account && + account.managedHomeRuntime === 'wsl' && + getWslSelectionKey(account.wslDistro) === distroKey + ? accountId + : null + } + return { + host: hostAccount && hostAccount.managedHomeRuntime !== 'wsl' ? selection.host : null, + wsl: nextWsl + } +} + +export function getCodexSelectionTargetForAccount( + account: CodexManagedAccount +): CodexAccountSelectionTarget { + if (account.managedHomeRuntime === 'wsl') { + return { runtime: 'wsl', wslDistro: account.wslDistro ?? null } + } + return { runtime: 'host' } +} + +export function getWslSelectionKey(wslDistro: string | null | undefined): string { + return normalizeWslDistro(wslDistro) ?? '__default__' +} + +function normalizeWslDistro(wslDistro: string | null | undefined): string | null { + const trimmed = wslDistro?.trim() + return trimmed ? trimmed : null +} diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 52ef01a220e..34b712c0131 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -23,6 +23,11 @@ vi.mock('node:os', async () => { } }) +function decodeEncodedWslBashCommand(command: string): string { + const encoded = command.match(/^set -o pipefail; printf %s '([^']+)' \| base64 -d \| bash$/)?.[1] + return encoded ? Buffer.from(encoded, 'base64').toString('utf8') : command +} + function createSettings(overrides: Partial = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true @@ -61,6 +66,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalAllowOsc52Clipboard: false, setupScriptLaunchMode: 'split-vertical', terminalScrollbackBytes: 10_000_000, + localAccountRuntime: 'host', + localAccountWslDistro: null, openLinksInApp: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', @@ -395,6 +402,7 @@ describe('CodexAccountService config sync', () => { ) vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn(), spawn: spawnMock })) vi.doMock('../codex-cli/command', () => ({ @@ -419,6 +427,314 @@ describe('CodexAccountService config sync', () => { expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1) }) + it('adds a managed Codex account inside WSL when the account context is WSL', async () => { + vi.resetModules() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + const wslManagedHomePath = join(testState.userDataDir, 'wsl-managed-home') + const wslConfigPath = join(testState.userDataDir, 'wsl-config.toml') + const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-id-for-test/home' + writeFileSync(wslConfigPath, 'sandbox_mode = "danger-full-access"\n', 'utf-8') + + const execFileSyncMock = vi.fn((_command: string, args: string[]) => { + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + expect(args.slice(0, 2)).toEqual(['-d', 'Debian']) + if (script.includes('WSL_DISTRO_NAME')) { + return 'Debian\n/home/alice\n' + } + if (script.includes('readlink -f')) { + return `${wslLinuxHomePath}\n` + } + mkdirSync(wslManagedHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-id-for-test\n') + return '' + }) + const spawnMock = vi.fn((command: string, args: string[]) => { + expect(command).toBe('wsl.exe') + expect(args).toEqual([ + '-d', + 'Debian', + '--', + 'bash', + '-lc', + `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` + ]) + expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe( + 'sandbox_mode = "danger-full-access"\n' + ) + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + + const payload = Buffer.from(JSON.stringify({ email: 'wsl@example.com' })).toString( + 'base64url' + ) + writeFileSync( + join(wslManagedHomePath, 'auth.json'), + JSON.stringify({ tokens: { id_token: `header.${payload}.signature` } }), + 'utf-8' + ) + queueMicrotask(() => child.emit('close', 0)) + return child + }) + + vi.doMock('node:crypto', () => ({ + randomUUID: () => 'account-id-for-test' + })) + vi.doMock('node:child_process', () => ({ + execFileSync: execFileSyncMock, + spawn: spawnMock + })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => + path === wslManagedHomePath ? { distro: 'Debian', linuxPath: wslLinuxHomePath } : null + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: (linuxPath: string) => + linuxPath.endsWith('/.codex/config.toml') ? wslConfigPath : wslManagedHomePath + })) + + const settings = createSettings() + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.addAccount({ runtime: 'wsl', wslDistro: 'Debian' }) + + expect(result.accounts[0]).toMatchObject({ + email: 'wsl@example.com', + managedHomeRuntime: 'wsl', + wslDistro: 'Debian' + }) + expect(store.getSettings().codexManagedAccounts[0]).toMatchObject({ + managedHomePath: wslManagedHomePath, + wslLinuxHomePath, + managedHomeRuntime: 'wsl' + }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('reauthenticates a WSL managed Codex account inside its distro', async () => { + vi.resetModules() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + const wslManagedHomePath = join(testState.userDataDir, 'wsl-account', 'home') + const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-1/home' + mkdirSync(wslManagedHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8') + writeFileSync( + join(wslManagedHomePath, 'auth.json'), + JSON.stringify({ + tokens: { + id_token: `header.${Buffer.from(JSON.stringify({ email: 'old@example.com' })).toString( + 'base64url' + )}.signature` + } + }), + 'utf-8' + ) + + const execFileSyncMock = vi.fn((_command: string, args: string[]) => { + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + if (script.includes('readlink -f')) { + return `${wslLinuxHomePath}\n` + } + return '' + }) + const spawnMock = vi.fn((command: string, args: string[]) => { + expect(command).toBe('wsl.exe') + expect(args).toEqual([ + '-d', + 'Ubuntu', + '--', + 'bash', + '-lc', + `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` + ]) + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + writeFileSync( + join(wslManagedHomePath, 'auth.json'), + JSON.stringify({ + tokens: { + id_token: `header.${Buffer.from(JSON.stringify({ email: 'new@example.com' })).toString( + 'base64url' + )}.signature` + } + }), + 'utf-8' + ) + queueMicrotask(() => child.emit('close', 0)) + return child + }) + + vi.doMock('node:child_process', () => ({ + execFileSync: execFileSyncMock, + spawn: spawnMock + })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => + path === wslManagedHomePath ? { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } : null + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: () => wslManagedHomePath + })) + + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.reauthenticateAccount('account-1') + + expect(result.accounts[0]).toMatchObject({ + email: 'new@example.com', + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('removes a WSL managed account only after canonical path validation', async () => { + vi.resetModules() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + const wslManagedHomePath = join(testState.userDataDir, 'wsl-account', 'home') + const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-1/home' + mkdirSync(wslManagedHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8') + + vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn((_command: string, args: string[]) => { + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + if (script.includes('readlink -f')) { + return `${wslLinuxHomePath}\n` + } + return '' + }), + spawn: vi.fn() + })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => + path === wslManagedHomePath ? { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } : null + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: () => wslManagedHomePath + })) + + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.removeAccount('account-1') + + expect(result.accounts).toHaveLength(0) + expect(existsSync(wslManagedHomePath)).toBe(false) + expect(existsSync(join(testState.userDataDir, 'wsl-account'))).toBe(false) + expect(rateLimits.evictInactiveCodexCache).toHaveBeenCalledWith('account-1') + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('deselects active account via selectAccount(null)', async () => { const managedHomePath = createManagedHome( testState.userDataDir, @@ -460,6 +776,80 @@ describe('CodexAccountService config sync', () => { expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalled() }) + it('keeps Windows and WSL active Codex account selections separate', async () => { + const hostManagedHomePath = createManagedHome( + testState.userDataDir, + 'host-account', + '', + '{"account":"host"}\n' + ) + const wslManagedHomePath = + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-accounts\\wsl-account\\home' + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedHomePath: hostManagedHomePath, + managedHomeRuntime: 'host', + wslDistro: null, + wslLinuxHomePath: null, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/wsl-account/home', + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'host-account', + activeCodexManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: {} + } + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.selectAccountForTarget('wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(result.activeAccountId).toBe('host-account') + expect(result.activeAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + expect(store.getSettings().activeCodexManagedAccountId).toBe('host-account') + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + }) + it('removes an account and cleans up managed home', async () => { const managedHomePath = createManagedHome( testState.userDataDir, diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 8b6306e24ff..ce6414ddf0e 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -2,9 +2,9 @@ account lifecycle, path safety, login, and identity parsing in one audited main-process module so the managed-account boundary stays explicit. */ import { randomUUID } from 'node:crypto' -import { spawn } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { join, relative, resolve, sep } from 'node:path' +import { dirname, join, relative, resolve, sep } from 'node:path' import { homedir } from 'node:os' import { app } from 'electron' import { getSpawnArgsForWindows } from '../win32-utils' @@ -18,6 +18,19 @@ import { writeFileAtomically } from './fs-utils' import { resolveCodexCommand } from '../codex-cli/command' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' +import { buildEncodedWslBashCommand } from '../wsl-bash-command' +import { + getCodexSelectionTargetForAccount, + getSelectedCodexAccountIdForTarget, + normalizeCodexAccountSelectionTarget, + normalizeCodexRuntimeSelection, + pruneInvalidCodexRuntimeSelection, + removeCodexAccountIdFromSelection, + setSelectedCodexAccountIdForTarget, + type CodexAccountSelectionTarget +} from './runtime-selection' const LOGIN_TIMEOUT_MS = 120_000 const MAX_LOGIN_OUTPUT_CHARS = 4_000 @@ -34,6 +47,22 @@ type ResolvedCodexIdentity = { workspaceAccountId: string | null } +export type CodexAccountAddTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +type ManagedHomeLocation = { + managedHomePath: string + managedHomeRuntime: 'host' | 'wsl' + wslDistro: string | null + wslLinuxHomePath: string | null +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + export class CodexAccountService { // Why: account mutations read settings, do async work (login, rate-limit // refresh), then write settings. Without serialization, overlapping calls @@ -59,8 +88,8 @@ export class CodexAccountService { return this.getSnapshot() } - async addAccount(): Promise { - return this.serializeMutation(() => this.doAddAccount()) + async addAccount(target?: CodexAccountAddTarget): Promise { + return this.serializeMutation(() => this.doAddAccount(target)) } async reauthenticateAccount(accountId: string): Promise { @@ -75,9 +104,17 @@ export class CodexAccountService { return this.serializeMutation(() => this.doSelectAccount(accountId)) } - private async doAddAccount(): Promise { + async selectAccountForTarget( + accountId: string | null, + target?: CodexAccountSelectionTarget + ): Promise { + return this.serializeMutation(() => this.doSelectAccount(accountId, target)) + } + + private async doAddAccount(target?: CodexAccountAddTarget): Promise { const accountId = randomUUID() - const managedHomePath = this.createManagedHome(accountId) + const managedHome = this.createManagedHome(accountId, target) + const { managedHomePath } = managedHome try { this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath) @@ -92,6 +129,9 @@ export class CodexAccountService { id: accountId, email: identity.email, managedHomePath, + managedHomeRuntime: managedHome.managedHomeRuntime, + wslDistro: managedHome.wslDistro, + wslLinuxHomePath: managedHome.wslLinuxHomePath, providerAccountId: identity.providerAccountId, workspaceLabel: identity.workspaceLabel, workspaceAccountId: identity.workspaceAccountId, @@ -101,9 +141,17 @@ export class CodexAccountService { } const settings = this.store.getSettings() + const selection = normalizeCodexRuntimeSelection(settings) + const targetSelection = getCodexSelectionTargetForAccount(account) this.store.updateSettings({ codexManagedAccounts: [...settings.codexManagedAccounts, account], - activeCodexManagedAccountId: account.id + activeCodexManagedAccountId: + targetSelection.runtime === 'host' ? account.id : selection.host, + activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( + selection, + account.id, + targetSelection + ) }) this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.clearLastWrittenAuthJson(account.id) @@ -111,8 +159,8 @@ export class CodexAccountService { // Why: the new account becomes active, so the previous active account is // now inactive and its last-known usage should be cached for the switcher. - const outgoingAccountId = settings.activeCodexManagedAccountId - await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId) + const outgoingAccountId = getSelectedCodexAccountIdForTarget(settings, targetSelection) + await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, targetSelection) return this.getSnapshot() } catch (error) { this.safeRemoveManagedHome(managedHomePath) @@ -151,12 +199,15 @@ export class CodexAccountService { }) this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.clearLastWrittenAuthJson(accountId) - this.runtimeHome.syncForCurrentSelection() + this.runtimeHome.syncForCurrentSelection(getCodexSelectionTargetForAccount(account)) // Why: re-auth can change which actual Codex identity the managed home // points at. Force a fresh read immediately so the status bar cannot keep // showing the previous account's quota under the updated label. - await this.rateLimits.refreshForCodexAccountChange() + await this.rateLimits.refreshForCodexAccountChange( + undefined, + getCodexSelectionTargetForAccount(account) + ) return this.getSnapshot() } @@ -164,14 +215,17 @@ export class CodexAccountService { const account = this.requireAccount(accountId) const settings = this.store.getSettings() const nextAccounts = settings.codexManagedAccounts.filter((entry) => entry.id !== accountId) + const nextSelection = removeCodexAccountIdFromSelection( + normalizeCodexRuntimeSelection(settings), + accountId + ) const nextActiveId = - settings.activeCodexManagedAccountId === accountId - ? null - : settings.activeCodexManagedAccountId + settings.activeCodexManagedAccountId === accountId ? null : nextSelection.host this.store.updateSettings({ codexManagedAccounts: nextAccounts, - activeCodexManagedAccountId: nextActiveId + activeCodexManagedAccountId: nextActiveId, + activeCodexManagedAccountIdsByRuntime: nextSelection }) this.runtimeHome.syncForCurrentSelection() @@ -180,28 +234,49 @@ export class CodexAccountService { // so purge its cached usage to avoid stale entries. this.rateLimits.evictInactiveCodexCache(accountId) await this.rateLimits.refreshForCodexAccountChange( - settings.activeCodexManagedAccountId === accountId - ? settings.activeCodexManagedAccountId - : undefined + getSelectedCodexAccountIdForTarget(settings, getCodexSelectionTargetForAccount(account)) === + accountId + ? accountId + : undefined, + getCodexSelectionTargetForAccount(account) ) return this.getSnapshot() } - private async doSelectAccount(accountId: string | null): Promise { + private async doSelectAccount( + accountId: string | null, + target?: CodexAccountSelectionTarget + ): Promise { + let effectiveTarget = target if (accountId !== null) { - this.requireAccount(accountId) + const account = this.requireAccount(accountId) + const accountTarget = getCodexSelectionTargetForAccount(account) + const requestedTarget = normalizeCodexAccountSelectionTarget(target ?? accountTarget) + const normalizedAccountTarget = normalizeCodexAccountSelectionTarget(accountTarget) + if ( + requestedTarget.runtime !== normalizedAccountTarget.runtime || + (requestedTarget.wslDistro !== null && + requestedTarget.wslDistro !== normalizedAccountTarget.wslDistro) + ) { + throw new Error('That Codex account belongs to a different runtime.') + } + effectiveTarget = accountTarget } const previousSettings = this.store.getSettings() - const outgoingAccountId = previousSettings.activeCodexManagedAccountId + const selection = normalizeCodexRuntimeSelection(previousSettings) + const outgoingAccountId = getSelectedCodexAccountIdForTarget(previousSettings, effectiveTarget) + const nextSelection = setSelectedCodexAccountIdForTarget(selection, accountId, effectiveTarget) this.store.updateSettings({ - activeCodexManagedAccountId: accountId + activeCodexManagedAccountId: + effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, + activeCodexManagedAccountIdsByRuntime: nextSelection }) this.safeSyncCanonicalConfigToManagedHomes() - this.runtimeHome.syncForCurrentSelection() + this.runtimeHome.syncForCurrentSelection(effectiveTarget) - await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId) + await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, effectiveTarget) return this.getSnapshot() } @@ -211,7 +286,8 @@ export class CodexAccountService { accounts: settings.codexManagedAccounts .map((account) => this.toSummary(account)) .sort((a, b) => b.updatedAt - a.updatedAt), - activeAccountId: settings.activeCodexManagedAccountId + activeAccountId: normalizeCodexRuntimeSelection(settings).host, + activeAccountIdsByRuntime: normalizeCodexRuntimeSelection(settings) } } @@ -219,6 +295,8 @@ export class CodexAccountService { return { id: account.id, email: account.email, + managedHomeRuntime: account.managedHomeRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, providerAccountId: account.providerAccountId ?? null, workspaceLabel: account.workspaceLabel ?? null, workspaceAccountId: account.workspaceAccountId ?? null, @@ -239,25 +317,99 @@ export class CodexAccountService { private normalizeActiveSelection(): void { const settings = this.store.getSettings() - if (!settings.activeCodexManagedAccountId) { - return - } - const hasActiveAccount = settings.codexManagedAccounts.some( - (entry) => entry.id === settings.activeCodexManagedAccountId + const selection = normalizeCodexRuntimeSelection(settings) + const nextSelection = pruneInvalidCodexRuntimeSelection( + selection, + settings.codexManagedAccounts ) - if (!hasActiveAccount) { - this.store.updateSettings({ activeCodexManagedAccountId: null }) + const changed = + nextSelection.host !== selection.host || + JSON.stringify(nextSelection.wsl) !== JSON.stringify(selection.wsl) + if (changed) { + this.store.updateSettings({ + activeCodexManagedAccountId: nextSelection.host, + activeCodexManagedAccountIdsByRuntime: nextSelection + }) } } - private createManagedHome(accountId: string): string { + private createManagedHome( + accountId: string, + target?: CodexAccountAddTarget + ): ManagedHomeLocation { + const wslHome = this.tryCreateWslManagedHome(accountId, target) + if (wslHome) { + return wslHome + } + const managedHomePath = join(this.getManagedAccountsRoot(), accountId, 'home') mkdirSync(managedHomePath, { recursive: true }) // Why: Codex expects CODEX_HOME to be a concrete directory it can own. We // pre-create the directory and leave a marker so future cleanup code can // prove the path belongs to Orca before deleting anything. writeFileSync(join(managedHomePath, '.orca-managed-home'), `${accountId}\n`, 'utf-8') - return this.assertManagedHomePath(managedHomePath) + return { + managedHomePath: this.assertManagedHomePath(managedHomePath), + managedHomeRuntime: 'host', + wslDistro: null, + wslLinuxHomePath: null + } + } + + private tryCreateWslManagedHome( + accountId: string, + target?: CodexAccountAddTarget + ): ManagedHomeLocation | null { + if (process.platform !== 'win32' || target?.runtime !== 'wsl') { + return null + } + + const distroArgs = target.wslDistro?.trim() ? ['-d', target.wslDistro.trim()] : [] + const infoOutput = execFileSync( + 'wsl.exe', + [...distroArgs, '--', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'], + { encoding: 'utf-8', timeout: 5000 } + ) + const [rawDistro, rawHome] = infoOutput + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim()) + const distro = target.wslDistro?.trim() || rawDistro + const home = rawHome + if (!distro || !home?.startsWith('/')) { + throw new Error('Could not resolve the active WSL home directory for Codex login.') + } + + const wslLinuxHomePath = `${home.replace(/\/$/, '')}/.local/share/orca/codex-accounts/${accountId}/home` + const markerPath = `${wslLinuxHomePath}/.orca-managed-home` + execFileSync( + 'wsl.exe', + [ + '-d', + distro, + '--', + 'bash', + '-lc', + `mkdir -p ${shellQuote(wslLinuxHomePath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}` + ], + { encoding: 'utf-8', timeout: 5000 } + ) + + const managedHomePath = toWindowsWslPath(wslLinuxHomePath, distro) + let trustedManagedHomePath: string + try { + trustedManagedHomePath = this.assertManagedHomePath(managedHomePath) + } catch (error) { + this.safeRemoveWslManagedHomeCandidate(distro, wslLinuxHomePath, accountId) + throw error + } + + return { + managedHomePath: trustedManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: distro, + wslLinuxHomePath + } } private safeSyncCanonicalConfigToManagedHomes(): void { @@ -277,15 +429,10 @@ export class CodexAccountService { } private syncCanonicalConfigToManagedHomes(): void { - const canonicalConfig = this.readCanonicalConfig() - if (canonicalConfig === null) { - return - } - const settings = this.store.getSettings() for (const account of settings.codexManagedAccounts) { try { - this.syncCanonicalConfigIntoManagedHome(account.managedHomePath, canonicalConfig) + this.syncCanonicalConfigIntoManagedHome(account.managedHomePath) } catch (error) { console.warn('[codex-accounts] Failed to sync managed config:', error) } @@ -294,7 +441,7 @@ export class CodexAccountService { private syncCanonicalConfigIntoManagedHome( managedHomePath: string, - canonicalConfig = this.readCanonicalConfig() + canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath) ): void { if (canonicalConfig === null) { return @@ -322,6 +469,31 @@ export class CodexAccountService { } } + private readCanonicalConfigForManagedHome(managedHomePath: string): string | null { + const wslInfo = parseWslUncPath(managedHomePath) + if (!wslInfo) { + return this.readCanonicalConfig() + } + + const managedRootMarker = '/.local/share/orca/codex-accounts/' + const markerIndex = wslInfo.linuxPath.indexOf(managedRootMarker) + if (markerIndex < 0) { + return null + } + const wslHome = wslInfo.linuxPath.slice(0, markerIndex) + const configPath = toWindowsWslPath(`${wslHome}/.codex/config.toml`, wslInfo.distro) + if (!existsSync(configPath)) { + return null + } + + try { + return readFileSync(configPath, 'utf-8') + } catch (error) { + console.warn('[codex-accounts] Failed to read WSL canonical config:', error) + return null + } + } + private writeManagedConfig(managedHomePath: string, contents: string): void { writeFileAtomically(join(managedHomePath, 'config.toml'), contents) } @@ -333,6 +505,62 @@ export class CodexAccountService { } private assertManagedHomePath(candidatePath: string): string { + const wslInfo = parseWslUncPath(candidatePath) + if (wslInfo) { + if ( + !wslInfo.linuxPath.includes('/.local/share/orca/codex-accounts/') || + !wslInfo.linuxPath.endsWith('/home') + ) { + throw new Error('Managed WSL Codex home is outside Orca account storage.') + } + + if (process.platform === 'win32') { + try { + const canonicalLinuxPath = execFileSync( + 'wsl.exe', + [ + '-d', + wslInfo.distro, + '--', + 'bash', + '-lc', + buildEncodedWslBashCommand( + [ + 'set -euo pipefail', + `candidate=${shellQuote(wslInfo.linuxPath)}`, + 'managed_root="${HOME%/}/.local/share/orca/codex-accounts"', + 'candidate_real=$(readlink -f -- "$candidate")', + 'managed_root_real=$(readlink -f -- "$managed_root")', + 'test -f "$candidate_real/.orca-managed-home"', + 'case "$candidate_real" in "$managed_root_real"/*/home) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' + ].join('\n') + ) + ], + { encoding: 'utf-8', timeout: 5000 } + ).trim() + if (!canonicalLinuxPath) { + throw new Error('Managed Codex home directory does not exist on disk.') + } + return toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) + } catch (error) { + throw new Error('Managed WSL Codex home is outside Orca account storage.', { + cause: error + }) + } + } + + if (wslInfo.linuxPath.split('/').includes('..')) { + throw new Error('Managed WSL Codex home is outside Orca account storage.') + } + if (!existsSync(candidatePath)) { + throw new Error('Managed Codex home directory does not exist on disk.') + } + if (!existsSync(join(candidatePath, '.orca-managed-home'))) { + throw new Error('Managed Codex home is missing Orca ownership marker.') + } + return candidatePath + } + const rootPath = this.getManagedAccountsRoot() const resolvedCandidate = resolve(candidatePath) const resolvedRoot = resolve(rootPath) @@ -376,6 +604,48 @@ export class CodexAccountService { return canonicalCandidate } + private safeRemoveWslManagedHomeCandidate( + distro: string, + linuxHomePath: string, + expectedAccountId: string + ): void { + // Why: WSL home creation can fail after mkdir/marker write but before the + // path is trusted. Cleanup must prove the marker/account ID inside WSL. + try { + execFileSync( + 'wsl.exe', + [ + '-d', + distro, + '--', + 'bash', + '-lc', + buildEncodedWslBashCommand( + [ + 'set -euo pipefail', + `candidate=${shellQuote(linuxHomePath)}`, + `expected_marker=${shellQuote(expectedAccountId)}`, + 'managed_root="${HOME%/}/.local/share/orca/codex-accounts"', + 'candidate_real=$(readlink -f -- "$candidate" 2>/dev/null || true)', + 'managed_root_real=$(readlink -f -- "$managed_root" 2>/dev/null || true)', + 'test -n "$candidate_real"', + 'test -n "$managed_root_real"', + 'case "$candidate_real" in "$managed_root_real"/*/home) ;; *) exit 0 ;; esac', + 'test -f "$candidate_real/.orca-managed-home"', + 'test "$(cat "$candidate_real/.orca-managed-home")" = "$expected_marker"', + 'rm -rf -- "$candidate_real"', + 'parent_dir=$(dirname -- "$candidate_real")', + 'case "$parent_dir" in "$managed_root_real"/*) rmdir -- "$parent_dir" 2>/dev/null || true ;; esac' + ].join('\n') + ) + ], + { encoding: 'utf-8', timeout: 5000 } + ) + } catch (error) { + console.warn('[codex-accounts] Failed to clean up WSL managed home candidate:', error) + } + } + private safeRemoveManagedHome(candidatePath: string): void { let managedHomePath: string try { @@ -387,6 +657,15 @@ export class CodexAccountService { rmSync(managedHomePath, { recursive: true, force: true }) + if (parseWslUncPath(managedHomePath)) { + try { + rmSync(dirname(managedHomePath), { recursive: true, force: true }) + } catch { + // Best-effort cleanup + } + return + } + // Why: managed homes live at //home. Removing // just the home/ leaf leaves an empty / directory behind. try { @@ -405,22 +684,45 @@ export class CodexAccountService { private async runCodexLogin(managedHomePath: string): Promise { await new Promise((resolvePromise, rejectPromise) => { - const codexCommand = resolveCodexCommand() - // Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file - // (e.g. codex.cmd from npm). Node's child_process.spawn cannot execute - // batch scripts directly without shell:true, but shell:true with an args - // array causes DEP0190 because args are concatenated, not escaped. - // Fix: detect batch scripts and invoke cmd.exe /c explicitly. - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['login']) - const child = spawn(spawnCmd, spawnArgs, { + const wslInfo = parseWslUncPath(managedHomePath) + const spawnConfig = wslInfo + ? { + command: 'wsl.exe', + args: [ + '-d', + wslInfo.distro, + '--', + 'bash', + '-lc', + `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}; exec codex login` + ], + env: process.env, + codexCommand: 'codex' + } + : (() => { + const codexCommand = resolveCodexCommand() + // Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file + // (e.g. codex.cmd from npm). Node's child_process.spawn cannot execute + // batch scripts directly without shell:true, but shell:true with an args + // array causes DEP0190 because args are concatenated, not escaped. + // Fix: detect batch scripts and invoke cmd.exe /c explicitly. + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['login']) + return { + command: spawnCmd, + args: spawnArgs, + env: { + ...process.env, + CODEX_HOME: managedHomePath + }, + codexCommand + } + })() + const child = spawn(spawnConfig.command, spawnConfig.args, { stdio: ['ignore', 'pipe', 'pipe'], // Why: route through cmd.exe for .cmd/.bat entrypoints would otherwise // flash a console window in the packaged GUI app on Windows. windowsHide: true, - env: { - ...process.env, - CODEX_HOME: managedHomePath - } + env: spawnConfig.env }) let settled = false @@ -457,7 +759,7 @@ export class CodexAccountService { // Why: ENOENT can mean either the codex binary doesn't exist OR the // script's shebang interpreter (node) isn't in PATH. When we resolved // codex to a full path, ENOENT almost certainly means node is missing. - const isBareCommand = codexCommand === 'codex' + const isBareCommand = spawnConfig.codexCommand === 'codex' const message = isEnoent ? isBareCommand ? 'Codex CLI not found.' diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index c956c3a63b2..c22585e7c1d 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -134,6 +134,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // the override makes the daemon path behave the same as the in-process // LocalPtyProvider. shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, shellReadySupported: opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false }) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 42c385c9464..c5cff0e99eb 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: this class owns the daemon socket protocol, + request routing, stream fanout, and session lifecycle in one place so + renderer/daemon request semantics stay auditable across platform branches. */ import { createServer, type Server, type Socket } from 'net' import { randomUUID } from 'crypto' import { performance } from 'perf_hooks' @@ -220,6 +223,7 @@ export class DaemonServer { envToDelete: p.envToDelete, command: p.command, shellOverride: p.shellOverride, + terminalWindowsWslDistro: p.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, shellReadySupported: p.shellReadySupported, streamClient: { diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index daed3cced1e..1f3416b1ce5 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -871,6 +871,43 @@ describe('createPtySubprocess', () => { ) }) + it('uses the preferred WSL distro for daemon WSL terminals with Windows cwd', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const cwd = mkdtempSync(join(tmpdir(), 'daemon-pty-wsl-distro-test-')) + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + rmSync(cwd, { recursive: true, force: true }) + } + + const normalizedCwd = cwd.replace(/\\/g, '/') + const driveMatch = normalizedCwd.match(/^([A-Za-z]):\/?(.*)$/) + const expectedLinuxCwd = driveMatch + ? `/mnt/${driveMatch[1].toLowerCase()}${driveMatch[2] ? `/${driveMatch[2]}` : ''}` + : '/mnt/c' + + expect(spawnMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Debian', '--', 'bash', '-c', `cd '${expectedLinuxCwd}' && exec bash -l`], + expect.objectContaining({ cwd: expect.any(String) }) + ) + }) + it('launches WSL for WSL worktree cwd even when a stale Windows shell override is present', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -912,7 +949,7 @@ describe('createPtySubprocess', () => { cols: 80, rows: 24, cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', - env: { CODEX_HOME: 'C:\\Users\\jin\\.codex' } + env: { CODEX_HOME: 'C:\\Users\\jin\\.codex', ORCA_CODEX_HOME: 'C:\\Users\\jin\\.codex' } }) } finally { if (platform) { @@ -924,7 +961,96 @@ describe('createPtySubprocess', () => { 'wsl.exe', ['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo' && exec bash -l"], expect.objectContaining({ - env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) + env: expect.not.objectContaining({ + CODEX_HOME: expect.anything(), + ORCA_CODEX_HOME: expect.anything() + }) + }) + ) + }) + + it('does not pass a WSL managed Codex home into daemon Windows terminals', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: 'C:\\Users\\jin\\repo', + env: { + CODEX_HOME: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home', + ORCA_CODEX_HOME: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + env: expect.not.objectContaining({ + CODEX_HOME: expect.anything(), + ORCA_CODEX_HOME: expect.anything() + }) + }) + ) + }) + + it('routes daemon default WSL terminals to the Codex home distro without losing cwd', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const cwd = mkdtempSync(join(tmpdir(), 'daemon-pty-wsl-codex-home-cwd-')) + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd, + shellOverride: 'wsl.exe', + env: { + CODEX_HOME: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home', + ORCA_CODEX_HOME: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + rmSync(cwd, { recursive: true, force: true }) + } + + const normalizedCwd = cwd.replace(/\\/g, '/') + const driveMatch = normalizedCwd.match(/^([A-Za-z]):\/?(.*)$/) + const expectedLinuxCwd = driveMatch + ? `/mnt/${driveMatch[1].toLowerCase()}${driveMatch[2] ? `/${driveMatch[2]}` : ''}` + : '/mnt/c' + + expect(spawnMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'bash', '-c', `cd '${expectedLinuxCwd}' && exec bash -l`], + expect.objectContaining({ + env: expect.objectContaining({ + CODEX_HOME: '/home/jin/.local/share/orca/codex-accounts/a/home', + ORCA_CODEX_HOME: '/home/jin/.local/share/orca/codex-accounts/a/home', + WSLENV: expect.stringContaining('CODEX_HOME') + }) }) ) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index c84f003f2ee..dafe2ca5b89 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -19,9 +19,10 @@ import { import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args' import { resolveEffectiveWindowsPowerShell } from '../providers/windows-powershell' import { isPwshAvailable } from '../pwsh' -import { isHostCodexHomeForWsl } from '../pty/codex-home-wsl-env' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { parseWslPath } from '../wsl' +import { addWslEnvKeys } from '../wsl-env' import { getWslContextFromSessionId } from './wsl-session-context' const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const @@ -38,6 +39,7 @@ export type PtySubprocessOptions = { * Overrides env.COMSPEC / env.SHELL resolution inside the daemon so a user * who picks "New WSL terminal" from the "+" menu actually gets WSL. */ shellOverride?: string + terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' } @@ -81,6 +83,13 @@ function removeInheritedDevAgentHookEndpoint( } } +function getWslContextFromPreferredDistro( + distro: string | null | undefined +): { distro: string } | undefined { + const trimmed = distro?.trim() + return trimmed ? { distro: trimmed } : undefined +} + 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. @@ -205,13 +214,18 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl const cwdWslInfo = process.platform === 'win32' ? parseWslPath(opts.cwd ?? '') : null const sessionWslContext = process.platform === 'win32' ? getWslContextFromSessionId(opts.sessionId) : undefined + const preferredWslContext = + process.platform === 'win32' + ? getWslContextFromPreferredDistro(opts.terminalWindowsWslDistro) + : undefined // Why: WSL worktree cwd is the repo's execution environment. Older persisted // tabs can carry a PowerShell/cmd shellOverride; ignore it so reconnects and // daemon-backed terminals enter the WSL distro just like LocalPtyProvider. let shellPath = cwdWslInfo || sessionWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) let shellArgs: string[] - let spawnCwd = opts.cwd || getDefaultCwd() + const requestedCwd = opts.cwd || getDefaultCwd() + let spawnCwd = requestedCwd let validationCwd = spawnCwd if (process.platform === 'win32') { @@ -245,18 +259,57 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl shellPath, spawnCwd, getDefaultCwd(), - sessionWslContext + sessionWslContext ?? preferredWslContext ) shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd - if ( - pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' && - isHostCodexHomeForWsl(env.CODEX_HOME) - ) { - // Why: Orca's selected Codex runtime home is host-local. WSL Codex must - // use its Linux-side ~/.codex instead of inheriting a Windows path. + const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null + if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { + if (codexHomeWslInfo) { + const launchWslDistro = + cwdWslInfo?.distro ?? sessionWslContext?.distro ?? preferredWslContext?.distro + if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } else { + env.CODEX_HOME = codexHomeWslInfo.linuxPath + env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath + // Why: wsl.exe only imports non-default env vars named in WSLENV. + addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + if (!launchWslDistro) { + const resolved = resolveWindowsShellLaunchArgs( + shellPath, + requestedCwd, + getDefaultCwd(), + { + distro: codexHomeWslInfo.distro + } + ) + shellArgs = resolved.shellArgs + spawnCwd = resolved.effectiveCwd + validationCwd = resolved.validationCwd + } + } + } else if (isHostCodexHomeForWsl(env.CODEX_HOME)) { + // Why: Orca's selected Codex runtime home is host-local. WSL Codex + // must use its Linux-side ~/.codex instead of a Windows path. + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } else if (env.CODEX_HOME) { + addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + } + if (env.CLAUDE_CONFIG_DIR) { + // Why: managed WSL Claude accounts pass a Linux CLAUDE_CONFIG_DIR + // through Windows wsl.exe; non-default env vars need WSLENV import. + addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) + } + } else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) { + // Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use + // them. ORCA_CODEX_HOME must go too because shell-ready scripts restore + // CODEX_HOME from it after user profiles run. delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME } } else { // Why: any Orca-injected overlay env that user rc files can clobber diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 672eddd4805..90114e45b7b 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -19,6 +19,7 @@ export type CreateOrAttachOptions = { * daemon path honors per-tab shell selection the same way LocalPtyProvider * does. */ shellOverride?: string + terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' shellReadySupported?: boolean streamClient: { onData: (data: string) => void; onExit: (code: number) => void } @@ -42,6 +43,7 @@ export type TerminalHostOptions = { envToDelete?: string[] command?: string shellOverride?: string + terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' }) => SubprocessHandle // Why: on graceful shutdown, the host writes final checkpoints for all live @@ -106,6 +108,7 @@ export class TerminalHost { envToDelete: opts.envToDelete, command: opts.command, shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation }) diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index a1b5501db18..f697d54e04e 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -71,6 +71,8 @@ export type CreateOrAttachRequest = { * instead of defaulting to COMSPEC (which is always cmd.exe on Windows) * or the hard-coded powershell.exe fallback. */ shellOverride?: string + /** Preferred WSL distro for generic `wsl.exe` launches. */ + terminalWindowsWslDistro?: string | null /** Why: the UI keeps PowerShell as one shell family, but the runtime may * need to substitute pwsh.exe for powershell.exe when the user selected * PowerShell 7+. Forward the persisted implementation choice so the daemon diff --git a/src/main/daemon/wsl-session-context.ts b/src/main/daemon/wsl-session-context.ts index 372407f9a5c..f6d568a7042 100644 --- a/src/main/daemon/wsl-session-context.ts +++ b/src/main/daemon/wsl-session-context.ts @@ -4,6 +4,7 @@ import { parsePtySessionId } from './pty-session-id' export type WslSessionContext = { distro: string + treatPosixCwdAsWsl: true } export function getWslContextFromSessionId(sessionId: string): WslSessionContext | undefined { @@ -12,5 +13,5 @@ export function getWslContextFromSessionId(sessionId: string): WslSessionContext ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath : undefined const wslInfo = worktreePath ? parseWslPath(worktreePath) : null - return wslInfo ? { distro: wslInfo.distro } : undefined + return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined } diff --git a/src/main/index.ts b/src/main/index.ts index 1a8b8908046..8ba2c81c749 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -57,10 +57,17 @@ import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' import { acquireSingleInstanceLock } from './startup/single-instance-lock' import { RateLimitService } from './rate-limits/service' +import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' +import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' import { attachMainWindowServices } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' import { CodexAccountService } from './codex-accounts/service' import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' +import { + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './codex-accounts/runtime-selection' +import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService } from './codex/hook-service' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' @@ -307,8 +314,8 @@ if (hasSingleInstanceLock) { enableMainProcessGpuFeatures() } -function prepareCodexRuntimeHomeForLaunch(): string { - const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch() +function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { + const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) try { // Why: launch prep is reachable after startup via PTY/runtime paths; honor @@ -487,7 +494,7 @@ function openMainWindow(): BrowserWindow { store, runtime, prepareCodexRuntimeHomeForLaunch, - () => claudeRuntimeAuth!.prepareForClaudeLaunch(), + (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), { onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { if (window.webContents.id === webContentsId) { @@ -1048,8 +1055,14 @@ app.whenReady().then(async () => { codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome) claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) - rateLimits.setCodexHomePathResolver(() => codexRuntimeHome!.prepareForRateLimitFetch()) - rateLimits.setClaudeAuthPreparationResolver(() => claudeRuntimeAuth!.prepareForRateLimitFetch()) + rateLimits.setCodexHomePathResolver((target) => + codexRuntimeHome!.prepareForRateLimitFetch(target) + ) + rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) + rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) + rateLimits.setClaudeAuthPreparationResolver((target) => + claudeRuntimeAuth!.prepareForRateLimitFetch(target) + ) rateLimits.setSettingsResolver(() => store!.getSettings()) keybindings = new KeybindingService({ homePath: app.getPath('home'), @@ -1058,14 +1071,32 @@ app.whenReady().then(async () => { browserManager.setSettingsResolver(() => ({ keybindings: keybindings?.getOverrides() })) rateLimits.setInactiveClaudeAccountsResolver(() => { const settings = store!.getSettings() + const activeIds = new Set( + [ + normalizeClaudeRuntimeSelection(settings).host, + ...Object.values(normalizeClaudeRuntimeSelection(settings).wsl) + ].filter(Boolean) + ) return settings.claudeManagedAccounts - .filter((account) => account.id !== settings.activeClaudeManagedAccountId) - .map((account) => ({ id: account.id, managedAuthPath: account.managedAuthPath })) + .filter((account) => !activeIds.has(account.id)) + .map((account) => ({ + id: account.id, + managedAuthPath: account.managedAuthPath, + managedAuthRuntime: account.managedAuthRuntime, + wslDistro: account.wslDistro, + wslLinuxAuthPath: account.wslLinuxAuthPath + })) }) rateLimits.setInactiveCodexAccountsResolver(() => { const settings = store!.getSettings() + const activeIds = new Set( + [ + normalizeCodexRuntimeSelection(settings).host, + ...Object.values(normalizeCodexRuntimeSelection(settings).wsl) + ].filter(Boolean) + ) return settings.codexManagedAccounts - .filter((account) => account.id !== settings.activeCodexManagedAccountId) + .filter((account) => !activeIds.has(account.id)) .map((account) => ({ id: account.id, managedHomePath: account.managedHomePath })) }) const runtimeService = new OrcaRuntimeService(store, stats, { @@ -1249,7 +1280,7 @@ app.whenReady().then(async () => { runtime, prepareCodexRuntimeHomeForLaunch, () => store!.getSettings(), - () => claudeRuntimeAuth!.prepareForClaudeLaunch(), + (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), store ) // Why: headless servers have no renderer graph publisher. Publish an diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 2fb289e3ece..d24e737d6fc 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -10,7 +10,7 @@ import type { FloatingTerminalCwdRequest, MarkdownDocument } from '../../shared/ import type { Store } from '../persistence' import { getDevInstanceIdentity } from '../startup/dev-instance-identity' import { isPwshAvailable } from '../pwsh' -import { isWslAvailable } from '../wsl' +import { isWslAvailable, listWslDistros } from '../wsl' import { setUnreadDockBadgeCount } from '../dock/unread-badge' import { authorizeExternalPath } from './filesystem-auth' import { @@ -117,6 +117,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp }) ipcMain.handle('wsl:isAvailable', (): boolean => isWslAvailable()) + ipcMain.handle('wsl:listDistros', (): string[] => listWslDistros()) ipcMain.handle('pwsh:isAvailable', (): boolean => isPwshAvailable()) // Why: ABC, Polish Pro, US Extended, ABC Extended, and every CJK Roman diff --git a/src/main/ipc/claude-accounts.ts b/src/main/ipc/claude-accounts.ts index 8d62c8c060a..2fbaff81ef8 100644 --- a/src/main/ipc/claude-accounts.ts +++ b/src/main/ipc/claude-accounts.ts @@ -1,16 +1,25 @@ import { ipcMain } from 'electron' -import type { ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeAccountAddTarget, ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountService): void { ipcMain.handle('claudeAccounts:list', () => claudeAccounts.listAccounts()) - ipcMain.handle('claudeAccounts:add', () => claudeAccounts.addAccount()) + ipcMain.handle('claudeAccounts:add', (_event, args?: ClaudeAccountAddTarget) => + claudeAccounts.addAccount(args) + ) ipcMain.handle('claudeAccounts:reauthenticate', (_event, args: { accountId: string }) => claudeAccounts.reauthenticateAccount(args.accountId) ) ipcMain.handle('claudeAccounts:remove', (_event, args: { accountId: string }) => claudeAccounts.removeAccount(args.accountId) ) - ipcMain.handle('claudeAccounts:select', (_event, args: { accountId: string | null }) => - claudeAccounts.selectAccount(args.accountId) + ipcMain.handle( + 'claudeAccounts:select', + (_event, args: { accountId: string | null } & ClaudeAccountSelectionTarget) => { + if (!args.runtime) { + return claudeAccounts.selectAccount(args.accountId) + } + return claudeAccounts.selectAccountForTarget(args.accountId, args) + } ) } diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index 12b132eca51..4e5f195e98a 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -1,16 +1,28 @@ import { ipcMain } from 'electron' -import type { CodexAccountService } from '../codex-accounts/service' +import type { CodexAccountAddTarget, CodexAccountService } from '../codex-accounts/service' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' export function registerCodexAccountHandlers(codexAccounts: CodexAccountService): void { ipcMain.handle('codexAccounts:list', () => codexAccounts.listAccounts()) - ipcMain.handle('codexAccounts:add', () => codexAccounts.addAccount()) + ipcMain.handle('codexAccounts:add', (_event, args?: CodexAccountAddTarget) => + codexAccounts.addAccount(args) + ) ipcMain.handle('codexAccounts:reauthenticate', (_event, args: { accountId: string }) => codexAccounts.reauthenticateAccount(args.accountId) ) ipcMain.handle('codexAccounts:remove', (_event, args: { accountId: string }) => codexAccounts.removeAccount(args.accountId) ) - ipcMain.handle('codexAccounts:select', (_event, args: { accountId: string | null }) => - codexAccounts.selectAccount(args.accountId) + ipcMain.handle( + 'codexAccounts:select', + (_event, args: { accountId: string | null } & CodexAccountSelectionTarget) => { + if (!args.runtime) { + // Why: older renderer surfaces selected by account id only. Let the + // service infer the account's runtime instead of treating missing + // runtime as Windows/host and rejecting valid WSL accounts. + return codexAccounts.selectAccount(args.accountId) + } + return codexAccounts.selectAccountForTarget(args.accountId, args) + } ) } diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 6f7a492cc2c..05b6c92bd8f 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -62,7 +62,13 @@ import { runPreflightCheck } from './preflight' -type HandlerMap = Record Promise> +type HandlerMap = Record< + string, + ( + _event?: unknown, + args?: { force?: boolean; wslDistro?: string | null; wslDefault?: boolean } + ) => Promise +> describe('preflight', () => { const originalPlatform = process.platform @@ -381,6 +387,30 @@ describe('preflight', () => { await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.toEqual(['claude']) }) + it('detects agents from the default WSL distro when requested', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'wsl.exe') { + throw new Error(`unexpected command ${String(command)}`) + } + const script = String(args[3]) + if (script === "command -v 'codex'") { + return { stdout: '/home/test/.local/bin/codex\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents({ wslDefault: true })).resolves.toEqual(['codex']) + expect(execFileAsyncMock).toHaveBeenCalledWith( + 'wsl.exe', + ['--', 'bash', '-lc', "command -v 'codex'"], + { encoding: 'utf-8', timeout: 5000 } + ) + }) + it('refreshes via preflight:refreshAgents by re-hydrating PATH before re-detecting', async () => { // Why: the Agents settings Refresh button calls this path. It must (1) ask // the shell hydrator for a fresh PATH, (2) merge any new segments, then diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index c38939a22be..8821a65ff48 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -14,6 +14,7 @@ const execFileAsync = promisify(execFile) type PreflightRuntimeContext = { wslDistro?: string | null + wslDefault?: boolean } export type PreflightStatus = { @@ -50,24 +51,32 @@ export function _resetPreflightCache(): void { cached = null } +type WslPreflightTarget = { + distro?: string +} + function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } async function execCommandInWsl( - distro: string, + target: WslPreflightTarget, command: string ): Promise<{ stdout: string; stderr: string }> { - return execFileAsync('wsl.exe', ['-d', distro, '--', 'bash', '-lc', command], { + const distroArgs = target.distro ? ['-d', target.distro] : [] + return execFileAsync('wsl.exe', [...distroArgs, '--', 'bash', '-lc', command], { encoding: 'utf-8', timeout: 5000 }) as Promise<{ stdout: string; stderr: string }> } -async function isCommandAvailable(command: string, wslDistro?: string): Promise { +async function isCommandAvailable( + command: string, + wslTarget?: WslPreflightTarget +): Promise { try { - await (wslDistro - ? execCommandInWsl(wslDistro, `${shellQuote(command)} --version`) + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`) : execFileAsync(command, ['--version'])) return true } catch { @@ -78,11 +87,11 @@ async function isCommandAvailable(command: string, wslDistro?: string): Promise< // Why: `which`/`where` is faster than spawning the agent binary itself and avoids // triggering any agent-specific startup side-effects. This gives a reliable // PATH-based check without requiring `--version` support from each agent. -async function isCommandOnPath(command: string, wslDistro?: string): Promise { +async function isCommandOnPath(command: string, wslTarget?: WslPreflightTarget): Promise { const finder = process.platform === 'win32' ? 'where' : 'which' try { - const { stdout } = wslDistro - ? await execCommandInWsl(wslDistro, `command -v ${shellQuote(command)}`) + const { stdout } = wslTarget + ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`) : await execFileAsync(finder, [command], { encoding: 'utf-8' }) return stdout .split(/\r?\n/) @@ -98,18 +107,26 @@ const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).map(([id, config]) cmd: config.detectCmd })) -function getPreflightWslDistro(context?: PreflightRuntimeContext): string | null { +function getPreflightWslTarget(context?: PreflightRuntimeContext): WslPreflightTarget | null { + if (process.platform !== 'win32') { + return null + } const distro = context?.wslDistro?.trim() - return process.platform === 'win32' && distro ? distro : null + if (distro) { + return { distro } + } + return context?.wslDefault ? {} : null } async function detectCommandRuntime( command: string, context?: PreflightRuntimeContext -): Promise<{ installed: boolean; wslDistro?: string }> { - const wslDistro = getPreflightWslDistro(context) - if (wslDistro && (await isCommandAvailable(command, wslDistro))) { - return { installed: true, wslDistro } +): Promise<{ installed: boolean; wslTarget?: WslPreflightTarget }> { + const wslTarget = getPreflightWslTarget(context) + if (wslTarget) { + return (await isCommandAvailable(command, wslTarget)) + ? { installed: true, wslTarget } + : { installed: false } } if (await isCommandAvailable(command)) { return { installed: true } @@ -118,12 +135,11 @@ async function detectCommandRuntime( } export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { - const wslDistro = getPreflightWslDistro(context) + const wslTarget = getPreflightWslTarget(context) const checks = await Promise.all( KNOWN_AGENT_COMMANDS.map(async ({ id, cmd }) => ({ id, - installed: - (wslDistro ? await isCommandOnPath(cmd, wslDistro) : false) || (await isCommandOnPath(cmd)) + installed: await isCommandOnPath(cmd, wslTarget ?? undefined) })) ) return checks.filter((c) => c.installed).map((c) => c.id) @@ -178,10 +194,10 @@ export async function detectRemoteAgents(args: { connectionId: string }): Promis return result.agents } -async function isGhAuthenticated(wslDistro?: string): Promise { +async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise { try { - await (wslDistro - ? execCommandInWsl(wslDistro, `${shellQuote('gh')} auth status`) + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote('gh')} auth status`) : execFileAsync('gh', ['auth', 'status'], { encoding: 'utf-8' })) @@ -201,10 +217,10 @@ async function isGhAuthenticated(wslDistro?: string): Promise { // Why: parallel to isGhAuthenticated for the glab CLI. glab writes auth // status to stderr in some versions and stdout in others; check both. -async function isGlabAuthenticated(wslDistro?: string): Promise { +async function isGlabAuthenticated(wslTarget?: WslPreflightTarget): Promise { try { - await (wslDistro - ? execCommandInWsl(wslDistro, `${shellQuote('glab')} auth status`) + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote('glab')} auth status`) : execFileAsync('glab', ['auth', 'status'], { encoding: 'utf-8' })) return true } catch (error) { @@ -219,7 +235,7 @@ export async function runPreflightCheck( force = false, context?: PreflightRuntimeContext ): Promise { - const cacheable = !getPreflightWslDistro(context) + const cacheable = !getPreflightWslTarget(context) if (cacheable && cached && !force) { return cached } @@ -241,8 +257,8 @@ export async function runPreflightCheck( ]) const [ghAuthenticated, glabAuthenticated, bitbucket, azureDevOps, gitea] = await Promise.all([ - ghProbe.installed ? isGhAuthenticated(ghProbe.wslDistro) : Promise.resolve(false), - glabProbe.installed ? isGlabAuthenticated(glabProbe.wslDistro) : Promise.resolve(false), + ghProbe.installed ? isGhAuthenticated(ghProbe.wslTarget) : Promise.resolve(false), + glabProbe.installed ? isGlabAuthenticated(glabProbe.wslTarget) : Promise.resolve(false), getBitbucketAuthStatus(), getAzureDevOpsAuthStatus(), getGiteaAuthStatus() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index c48a5ff8ede..4ffb18facf9 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -24,6 +24,7 @@ import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' import { isClaudeAuthSwitchInProgress, @@ -56,6 +57,8 @@ import { } from '../agent-hooks/migration-unsupported-pty-state' import { parseWslPath } from '../wsl' import { mergePersistedWindowsPath } from '../pty/windows-environment-path' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId. null = local provider. @@ -286,6 +289,38 @@ function shouldSkipCodexHomeEnvForWindowsShell( } const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const +type GetSelectedCodexHomePath = (target?: CodexAccountSelectionTarget) => string | null +type PrepareClaudeAuth = ( + target?: ClaudeAccountSelectionTarget +) => Promise + +function getCodexSelectionTargetForPty( + shellPath: string | undefined, + cwd: string | undefined, + wslDistro?: string | null +): CodexAccountSelectionTarget { + const wslPath = typeof cwd === 'string' ? parseWslPath(cwd) : null + if (isWslShellName(shellPath) || wslPath) { + return { runtime: 'wsl', wslDistro: wslPath?.distro ?? wslDistro ?? null } + } + return { runtime: 'host' } +} + +function getCompatibleSelectedCodexHomePath( + target: CodexAccountSelectionTarget, + selectedCodexHomePath: string | null +): string | null { + if (!selectedCodexHomePath) { + return null + } + const wslInfo = parseWslPath(selectedCodexHomePath) + if (target.runtime === 'wsl') { + return wslInfo || !isHostCodexHomeForWsl(selectedCodexHomePath) ? selectedCodexHomePath : null + } + return wslInfo || (process.platform === 'win32' && isWslCodexHomeForHost(selectedCodexHomePath)) + ? null + : selectedCodexHomePath +} function readEnvWithProcessFallback( baseEnv: Record, @@ -795,9 +830,9 @@ export function unbindLocalProviderListeners(): void { export function registerPtyHandlers( mainWindow: BrowserWindow, runtime?: OrcaRuntimeService, - getSelectedCodexHomePath?: () => string | null, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, getSettings?: () => GlobalSettings, - prepareClaudeAuth?: () => Promise, + prepareClaudeAuth?: PrepareClaudeAuth, store?: Store ): void { // Remove any previously registered handlers so we can re-register them @@ -832,13 +867,19 @@ export function registerPtyHandlers( : undefined, pwshAvailable: () => isPwshAvailable(), buildSpawnEnv: (id, baseEnv, ctx) => { + const codexSelectionTarget: CodexAccountSelectionTarget = + ctx?.isWsl === true + ? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null } + : { runtime: 'host' } + const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + ) const env = buildPtyHostEnv(id, baseEnv, { isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), - selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, - // Why: WSL's inner shell cannot use a Windows userData CODEX_HOME. - // Leave Linux Codex on its native ~/.codex until we own a WSL home. - skipCodexHomeEnv: ctx?.isWsl === true, + selectedCodexHomePath, + skipCodexHomeEnv: ctx?.isWsl === true && !selectedCodexHomePath, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: ctx?.command, agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) @@ -1177,7 +1218,17 @@ export function registerPtyHandlers( if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } - const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth() : null + const daemonShellOverride = + process.platform === 'win32' && !args.connectionId + ? getSettings?.()?.terminalWindowsShell + : undefined + const codexSelectionTarget = getCodexSelectionTargetForPty( + daemonShellOverride, + args.cwd, + getSettings?.()?.terminalWindowsWslDistro ?? null + ) + const claudeAuth = + isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(codexSelectionTarget) : null if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } @@ -1195,12 +1246,16 @@ export function registerPtyHandlers( if (args.preAllocatedHandle) { env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } } - const daemonShellOverride = - process.platform === 'win32' && !args.connectionId - ? getSettings?.()?.terminalWindowsShell - : undefined + const selectedCodexHomePath = isDaemonHostSpawn + ? getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + ) + : null const skipCodexHomeEnv = - isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, args.cwd) + isDaemonHostSpawn && + shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, args.cwd) && + !selectedCodexHomePath if (isDaemonHostSpawn && sessionId) { if (!isSafePtySessionId(sessionId, app.getPath('userData'))) { throw new Error('Invalid PTY session id') @@ -1208,7 +1263,7 @@ export function registerPtyHandlers( env = buildPtyHostEnv(sessionId, env ?? {}, { isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), - selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, + selectedCodexHomePath, skipCodexHomeEnv, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, @@ -1247,6 +1302,7 @@ export function registerPtyHandlers( } if (process.platform === 'win32' && !args.connectionId) { spawnOptions.shellOverride = getSettings?.()?.terminalWindowsShell + spawnOptions.terminalWindowsWslDistro = getSettings?.()?.terminalWindowsWslDistro ?? null spawnOptions.terminalWindowsPowerShellImplementation = getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined @@ -1474,7 +1530,18 @@ export function registerPtyHandlers( if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } - const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth() : null + const initialShellOverride = + args.shellOverride ?? + (process.platform === 'win32' && !args.connectionId + ? getSettings?.()?.terminalWindowsShell + : undefined) + const initialSelectionTarget = getCodexSelectionTargetForPty( + initialShellOverride, + args.cwd, + getSettings?.()?.terminalWindowsWslDistro ?? null + ) + const claudeAuth = + isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } @@ -1602,8 +1669,21 @@ export function registerPtyHandlers( (process.platform === 'win32' && !args.connectionId ? getSettings?.()?.terminalWindowsShell : undefined) + const codexSelectionTarget = getCodexSelectionTargetForPty( + effectiveShellOverride, + args.cwd, + getSettings?.()?.terminalWindowsWslDistro ?? null + ) + const selectedCodexHomePath = isDaemonHostSpawn + ? getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + ) + : null const skipCodexHomeEnv = - isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, args.cwd) + isDaemonHostSpawn && + shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, args.cwd) && + !selectedCodexHomePath if (isDaemonHostSpawn) { if (effectiveSessionId === undefined) { // Should be unreachable: the expression above returns a string when @@ -1627,7 +1707,7 @@ export function registerPtyHandlers( buildPtyHostEnv(sessionIdForEnv, env, { isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), - selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, + selectedCodexHomePath, skipCodexHomeEnv, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, @@ -1704,6 +1784,7 @@ export function registerPtyHandlers( // the persisted implementation choice through spawnOptions so both the // in-process and daemon-backed PTY paths can resolve the same effective // executable without inventing a fourth top-level shell. + spawnOptions.terminalWindowsWslDistro = getSettings?.()?.terminalWindowsWslDistro ?? null spawnOptions.terminalWindowsPowerShellImplementation = getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined @@ -2217,9 +2298,9 @@ export function registerPtyHandlers( export function registerHeadlessPtyRuntime( runtime: OrcaRuntimeService, - getSelectedCodexHomePath?: () => string | null, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, getSettings?: () => GlobalSettings, - prepareClaudeAuth?: () => Promise, + prepareClaudeAuth?: PrepareClaudeAuth, store?: Store ): void { // Why: headless `orca serve` has no renderer window, but the runtime still diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts index 703e9761721..63a50649468 100644 --- a/src/main/ipc/rate-limits.ts +++ b/src/main/ipc/rate-limits.ts @@ -1,9 +1,16 @@ import { ipcMain } from 'electron' import type { RateLimitService } from '../rate-limits/service' +import type { RateLimitRuntimeTarget } from '../../shared/rate-limit-types' export function registerRateLimitHandlers(rateLimits: RateLimitService): void { ipcMain.handle('rateLimits:get', () => rateLimits.getState()) ipcMain.handle('rateLimits:refresh', () => rateLimits.refresh()) + ipcMain.handle('rateLimits:refreshCodexForTarget', (_event, target: RateLimitRuntimeTarget) => + rateLimits.refreshCodexForTarget(target) + ) + ipcMain.handle('rateLimits:refreshClaudeForTarget', (_event, target: RateLimitRuntimeTarget) => + rateLimits.refreshClaudeForTarget(target) + ) ipcMain.handle('rateLimits:setPollingInterval', (_event, ms: number) => rateLimits.setPollingInterval(ms) ) diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index e06841f4043..09b2dc3e225 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -164,6 +164,7 @@ describe('LocalPtyProvider', () => { provider.configure({ buildSpawnEnv: (_id, env) => { env.CODEX_HOME = 'C:\\Users\\jin\\.codex' + env.ORCA_CODEX_HOME = 'C:\\Users\\jin\\.codex' return env } }) @@ -177,6 +178,30 @@ describe('LocalPtyProvider', () => { const spawnCall = spawnMock.mock.calls.at(-1)! expect(spawnCall[0]).toBe('wsl.exe') expect(spawnCall[2].env.CODEX_HOME).toBeUndefined() + expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined() + }) + + it('does not pass a WSL managed Codex home into Windows terminals', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env) => { + env.CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + env.ORCA_CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + return env + } + }) + + await provider.spawn({ + cols: 80, + rows: 24, + cwd: 'C:\\Users\\jin\\repo' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[2].env.CODEX_HOME).toBeUndefined() + expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined() }) it('preserves an explicit Linux Codex home for WSL terminals', async () => { @@ -197,6 +222,82 @@ describe('LocalPtyProvider', () => { const spawnCall = spawnMock.mock.calls.at(-1)! expect(spawnCall[0]).toBe('wsl.exe') expect(spawnCall[2].env.CODEX_HOME).toBe('/home/jin/.codex-alt') + expect(spawnCall[2].env.WSLENV).toContain('CODEX_HOME') + }) + + it('translates a WSL managed Codex home before launching a WSL terminal', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env) => { + env.CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + env.ORCA_CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + return env + } + }) + + await provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[2].env.CODEX_HOME).toBe('/home/jin/.local/share/orca/codex-accounts/a/home') + expect(spawnCall[2].env.ORCA_CODEX_HOME).toBe( + '/home/jin/.local/share/orca/codex-accounts/a/home' + ) + expect(spawnCall[2].env.WSLENV).toContain('CODEX_HOME') + expect(spawnCall[2].env.WSLENV).toContain('ORCA_CODEX_HOME') + }) + + it('does not pass a WSL managed Codex home into a different WSL distro', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env) => { + env.CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + env.ORCA_CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + return env + } + }) + + await provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Debian\\home\\jin\\repo' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[2].env.CODEX_HOME).toBeUndefined() + expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined() + }) + + it('uses the preferred WSL distro for Windows cwd WSL terminals', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + await provider.spawn({ + cols: 80, + rows: 24, + cwd: 'C:\\Users\\jin\\repo', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[1]).toEqual([ + '-d', + 'Debian', + '--', + 'bash', + '-c', + "cd '/mnt/c/Users/jin/repo' && exec bash -l" + ]) }) it('does not inherit parent Orca pane identity when caller omits pane env', async () => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 95bd66b6f5a..7e6772b97e5 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -31,7 +31,8 @@ import { STARTUP_COMMAND_READY_MAX_WAIT_MS } from './local-pty-shell-ready' import { removeInheritedNoColor } from '../pty/terminal-color-env' -import { isHostCodexHomeForWsl } from '../pty/codex-home-wsl-env' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' +import { addWslEnvKeys } from '../wsl-env' const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const @@ -93,10 +94,17 @@ function disposePtyListeners(id: string): void { function getWslContextFromWorktreeId( worktreeId: string | undefined -): { distro: string } | undefined { +): { distro: string; treatPosixCwdAsWsl: true } | undefined { const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined const wslInfo = worktreePath ? parseWslPath(worktreePath) : null - return wslInfo ? { distro: wslInfo.distro } : undefined + return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined +} + +function getWslContextFromPreferredDistro( + distro: string | null | undefined +): { distro: string } | undefined { + const trimmed = distro?.trim() + return trimmed ? { distro: trimmed } : undefined } function clearPtyState(id: string): void { @@ -146,7 +154,7 @@ export type LocalPtyProviderOptions = { buildSpawnEnv?: ( id: string, baseEnv: Record, - ctx?: { command?: string; isWsl?: boolean } + ctx?: { command?: string; isWsl?: boolean; wslDistro?: string | null } ) => Record /** Whether worktree-scoped shell history is enabled. When true (or absent) * and a worktreeId is provided, HISTFILE is scoped per-worktree. */ @@ -183,6 +191,10 @@ export class LocalPtyProvider implements IPtyProvider { const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null const worktreeWslContext = process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined + const preferredWslContext = + process.platform === 'win32' + ? getWslContextFromPreferredDistro(args.terminalWindowsWslDistro) + : undefined let shellPath: string let shellArgs: string[] @@ -236,7 +248,12 @@ export class LocalPtyProvider implements IPtyProvider { // same shellArgs for the same (shell, cwd) pair. The helper keeps CJK // UTF-8 setup (chcp 65001), PowerShell $PROFILE dot-sourcing, and the // wsl.exe /mnt/ cwd translation in one place. - const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd, worktreeWslContext) + const resolved = resolveWindowsShellLaunchArgs( + shellPath, + cwd, + defaultCwd, + worktreeWslContext ?? preferredWslContext + ) shellArgs = resolved.shellArgs effectiveCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd @@ -290,17 +307,56 @@ export class LocalPtyProvider implements IPtyProvider { } const isWslShell = Boolean(wslInfo) || pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' + const launchWslDistro = + wslInfo?.distro ?? worktreeWslContext?.distro ?? preferredWslContext?.distro ?? null const finalEnv = this.opts.buildSpawnEnv - ? this.opts.buildSpawnEnv(id, spawnEnv, { command: args.command, isWsl: isWslShell }) + ? this.opts.buildSpawnEnv(id, spawnEnv, { + command: args.command, + isWsl: isWslShell, + wslDistro: launchWslDistro + }) : spawnEnv - if ( - process.platform === 'win32' && - pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' && - isHostCodexHomeForWsl(finalEnv.CODEX_HOME) - ) { - // Why: Orca's selected Codex runtime home is host-local. WSL Codex must - // use its Linux-side ~/.codex instead of inheriting a Windows path. - delete finalEnv.CODEX_HOME + if (process.platform === 'win32') { + const codexHomeWslInfo = finalEnv.CODEX_HOME ? parseWslPath(finalEnv.CODEX_HOME) : null + if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { + if (codexHomeWslInfo) { + if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { + delete finalEnv.CODEX_HOME + delete finalEnv.ORCA_CODEX_HOME + } else { + finalEnv.CODEX_HOME = codexHomeWslInfo.linuxPath + finalEnv.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath + // Why: wsl.exe only imports non-default env vars named in WSLENV. + addWslEnvKeys(finalEnv, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + if (!launchWslDistro) { + const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd, { + distro: codexHomeWslInfo.distro + }) + shellArgs = resolved.shellArgs + effectiveCwd = resolved.effectiveCwd + validationCwd = resolved.validationCwd + } + } + } else if (isHostCodexHomeForWsl(finalEnv.CODEX_HOME)) { + // Why: Orca's selected Codex runtime home is host-local. WSL Codex + // must use its Linux-side ~/.codex instead of a Windows path. + delete finalEnv.CODEX_HOME + delete finalEnv.ORCA_CODEX_HOME + } else if (finalEnv.CODEX_HOME) { + addWslEnvKeys(finalEnv, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + } + if (finalEnv.CLAUDE_CONFIG_DIR) { + // Why: managed WSL Claude accounts pass a Linux CLAUDE_CONFIG_DIR + // through Windows wsl.exe; non-default env vars need WSLENV import. + addWslEnvKeys(finalEnv, ['CLAUDE_CONFIG_DIR']) + } + } else if (codexHomeWslInfo || isWslCodexHomeForHost(finalEnv.CODEX_HOME)) { + // Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use + // them. ORCA_CODEX_HOME must go too because shell-ready scripts restore + // CODEX_HOME from it after user profiles run. + delete finalEnv.CODEX_HOME + delete finalEnv.ORCA_CODEX_HOME + } } if (!wslInfo && process.platform !== 'win32') { // Why: any Orca-injected overlay env that user rc files can clobber diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 1288b8b07f3..7844b326911 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -41,6 +41,9 @@ export type PtySpawnOptions = { * changing the user's persistent default shell setting. Only consulted on * Windows; ignored on macOS/Linux where shell selection is not exposed. */ shellOverride?: string + /** Preferred WSL distro for generic `wsl.exe` launches. Worktree/session + * distro still wins when the cwd already identifies a WSL distro. */ + terminalWindowsWslDistro?: string | null /** Why: PowerShell is the top-level shell family in product terms, but on * Windows we may need to choose between inbox Windows PowerShell 5.1 and * pwsh.exe at spawn time. Threading the persisted implementation choice diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 7f2e13d596f..b702efaff93 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -131,7 +131,7 @@ describe('resolveWindowsShellLaunchArgs', () => { 'wsl.exe', '/home/alice/repo/subdir', 'C:\\Users\\alice', - { distro: 'Ubuntu' } + { distro: 'Ubuntu', treatPosixCwdAsWsl: true } ) expect(result.shellArgs).toEqual([ diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 5ff2ed26588..c032aa15194 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -27,6 +27,7 @@ export type WindowsShellLaunchArgs = { export type WindowsShellWslContext = { distro: string + treatPosixCwdAsWsl?: boolean } function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { @@ -84,7 +85,7 @@ export function resolveWindowsShellLaunchArgs( validationCwd: cwd } } - if (wslContext && cwd.startsWith('/')) { + if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/')) { return { shellArgs: buildWslShellArgs(cwd, wslContext.distro), effectiveCwd: defaultCwd, @@ -94,7 +95,7 @@ export function resolveWindowsShellLaunchArgs( const driveMatch = cwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/) const linuxCwd = driveMatch ? toLinuxPath(cwd) : '/mnt/c' return { - shellArgs: buildWslShellArgs(linuxCwd), + shellArgs: buildWslShellArgs(linuxCwd, wslContext?.distro), effectiveCwd: defaultCwd, validationCwd: cwd } diff --git a/src/main/pty/codex-home-wsl-env.test.ts b/src/main/pty/codex-home-wsl-env.test.ts index c98958cbdcc..1077ea9855e 100644 --- a/src/main/pty/codex-home-wsl-env.test.ts +++ b/src/main/pty/codex-home-wsl-env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isHostCodexHomeForWsl } from './codex-home-wsl-env' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from './codex-home-wsl-env' describe('isHostCodexHomeForWsl', () => { it('matches Windows paths that WSL Codex cannot use as CODEX_HOME', () => { @@ -14,4 +14,10 @@ describe('isHostCodexHomeForWsl', () => { expect(isHostCodexHomeForWsl('')).toBe(false) expect(isHostCodexHomeForWsl(undefined)).toBe(false) }) + + it('matches Linux paths that host Codex cannot use on Windows', () => { + expect(isWslCodexHomeForHost('/home/jin/.local/share/orca/codex-accounts/a/home')).toBe(true) + expect(isWslCodexHomeForHost('C:\\Users\\jin\\.codex')).toBe(false) + expect(isWslCodexHomeForHost(undefined)).toBe(false) + }) }) diff --git a/src/main/pty/codex-home-wsl-env.ts b/src/main/pty/codex-home-wsl-env.ts index 13efe2ad8c8..b642e7661f6 100644 --- a/src/main/pty/codex-home-wsl-env.ts +++ b/src/main/pty/codex-home-wsl-env.ts @@ -5,3 +5,11 @@ export function isHostCodexHomeForWsl(value: string | undefined): boolean { } return /^[A-Za-z]:(?:[\\/]|$)/.test(trimmed) || trimmed.startsWith('\\\\') } + +export function isWslCodexHomeForHost(value: string | undefined): boolean { + const trimmed = value?.trim() + if (!trimmed) { + return false + } + return trimmed.startsWith('/') +} diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 6bca4c9146f..b68ce2cd671 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -99,6 +99,30 @@ describe('fetchClaudeRateLimits', () => { } }) + it('does not read host credentials when WSL config resolution fails', async () => { + await expect( + fetchClaudeRateLimits({ + authPreparation: { + configDir: '/Users/test/.claude', + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: null, + envPatch: {}, + stripAuthEnv: true, + provenance: 'wsl:Ubuntu:system' + } + }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'WSL Claude config unavailable for Ubuntu' + }) + + expect(readFileMock).not.toHaveBeenCalled() + expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled() + expect(fetchViaPty).not.toHaveBeenCalled() + }) + it('reads scoped default-config Keychain credentials for OAuth usage fetches', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { @@ -168,7 +192,10 @@ describe('fetchClaudeRateLimits', () => { status: 'ok' }) - expect(readFileMock).toHaveBeenCalledWith('/Users/test/.claude/.credentials.json', 'utf-8') + expect(readFileMock).toHaveBeenCalledWith( + join('/Users/test/.claude', '.credentials.json'), + 'utf-8' + ) expect(netFetchMock).toHaveBeenCalledWith( 'https://api.anthropic.com/api/oauth/usage', expect.objectContaining({ diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 0d56e06fbe9..22d2aed30f4 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -1,11 +1,13 @@ /* eslint-disable max-lines -- Why: this module keeps Claude credential source ordering, OAuth usage fetch semantics, and PTY fallback behavior together so subscription usage state cannot drift across code paths. */ +import { existsSync, lstatSync, readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' import { net, session } from 'electron' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { parseWslUncPath } from '../../shared/wsl-paths' import { fetchViaPty } from './claude-pty' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { @@ -307,6 +309,17 @@ async function fetchViaOAuth(token: string): Promise { export async function fetchClaudeRateLimits(options?: { authPreparation?: ClaudeRuntimeAuthPreparation }): Promise { + if (options?.authPreparation?.runtime === 'wsl' && !options.authPreparation.wslLinuxConfigDir) { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `WSL Claude config unavailable for ${options.authPreparation.wslDistro ?? 'default distro'}`, + status: 'error' + } + } + // Path A: try OAuth API if we have a genuine OAuth token const oauthCredentials = await readOAuthCredentials(options?.authPreparation?.configDir) if (oauthCredentials.token) { @@ -368,6 +381,9 @@ export async function fetchClaudeRateLimits(options?: { export type InactiveClaudeAccountInfo = { id: string managedAuthPath: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxAuthPath?: string | null } // Why: reads an inactive account's OAuth token directly from its managed @@ -375,6 +391,14 @@ export type InactiveClaudeAccountInfo = { // Using ClaudeRuntimeAuthService would overwrite the active account's auth. async function readManagedOAuthToken(account: InactiveClaudeAccountInfo): Promise { try { + if (account.managedAuthRuntime === 'wsl') { + const managedAuthPath = resolveOwnedWslClaudeManagedAuthPath(account) + if (!managedAuthPath) { + return null + } + const raw = readClaudeManagedAuthFile(managedAuthPath, '.credentials.json') + return raw ? parseOAuthCredentialsJson(raw).token : null + } const managedAuthPath = resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { adoptLegacyMarker: true }) @@ -395,6 +419,36 @@ async function readManagedOAuthToken(account: InactiveClaudeAccountInfo): Promis } } +function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccountInfo): string | null { + if (process.platform !== 'win32') { + return null + } + const wslInfo = parseWslUncPath(account.managedAuthPath) + if (!wslInfo || (account.wslDistro && wslInfo.distro !== account.wslDistro)) { + return null + } + const linuxPath = account.wslLinuxAuthPath ?? wslInfo.linuxPath + if ( + !linuxPath.includes('/.local/share/orca/claude-accounts/') || + !linuxPath.endsWith(`/${account.id}/auth`) + ) { + return null + } + try { + const markerPath = path.join(account.managedAuthPath, '.orca-managed-claude-auth') + if ( + !existsSync(markerPath) || + lstatSync(markerPath).isSymbolicLink() || + readFileSync(markerPath, 'utf-8').trim() !== account.id + ) { + return null + } + return account.managedAuthPath + } catch { + return null + } +} + export async function fetchManagedAccountUsage( account: InactiveClaudeAccountInfo ): Promise { diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index 8f0dc78bc23..1e31e26afa8 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -129,6 +129,10 @@ const STARTUP_DELAY_MS = 2_000 const SETTLE_AFTER_STOP_MS = 2_000 const SETTLE_AFTER_CLAUDE_21_USAGE_MS = 8_000 +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + function describeClaudeUsageFailure(output: string): string { if (RATE_LIMITED_RE.test(output)) { return 'Claude usage is rate limited right now.' @@ -167,14 +171,34 @@ export async function fetchViaPty(options?: { // those need cmd.exe as an interpreter. Always route through cmd.exe on win32 // and ensure the command path is properly quoted if it contains spaces. const isWin32 = process.platform === 'win32' - const spawnFile = isWin32 ? 'cmd.exe' : claudeCommand - const spawnArgs = isWin32 ? ['/c', `"${claudeCommand}"`] : [] - const spawnEnv = applyClaudeEnvPatch( { ...process.env, TERM: 'xterm-256color' } as Record, options?.authPreparation?.envPatch ?? {}, { stripAuthEnv: options?.authPreparation?.stripAuthEnv ?? false } ) + const authPreparation = options?.authPreparation + const wslConfig = + authPreparation?.runtime === 'wsl' && + authPreparation.wslDistro && + authPreparation.wslLinuxConfigDir + ? { + distro: authPreparation.wslDistro, + linuxConfigDir: authPreparation.wslLinuxConfigDir + } + : null + const spawnFile = wslConfig ? 'wsl.exe' : isWin32 ? 'cmd.exe' : claudeCommand + const spawnArgs = wslConfig + ? [ + '-d', + wslConfig.distro, + '--', + 'bash', + '-lc', + `export CLAUDE_CONFIG_DIR=${shellQuote(wslConfig.linuxConfigDir)}; exec claude` + ] + : isWin32 + ? ['/c', `"${claudeCommand}"`] + : [] const term = pty.spawn(spawnFile, spawnArgs, { name: 'xterm-256color', diff --git a/src/main/rate-limits/claude-rate-limit-target.test.ts b/src/main/rate-limits/claude-rate-limit-target.test.ts new file mode 100644 index 00000000000..8ae7722f656 --- /dev/null +++ b/src/main/rate-limits/claude-rate-limit-target.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/types' +import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target' + +function legacySettingsWithoutAccountRuntime(settings: GlobalSettings): GlobalSettings { + const next = { ...settings } as Partial + delete next.localAccountRuntime + return next as GlobalSettings +} + +describe('getInitialClaudeRateLimitTarget', () => { + it('uses the configured WSL account runtime before agent detection runtime', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Fedora', + localAgentRuntime: 'host', + terminalWindowsWslDistro: 'Debian' + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Fedora' }) + }) + + it('uses the single selected WSL account distro when account runtime is WSL default', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + activeClaudeManagedAccountIdsByRuntime: { + host: 'host-account-1', + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses the configured WSL agent runtime and distro', () => { + expect( + getInitialClaudeRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu', + terminalWindowsWslDistro: 'Debian' + }), + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses the Windows WSL terminal setting when agent runtime is implicit', () => { + expect( + getInitialClaudeRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }), + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses a single WSL-only active account after restart', () => { + expect( + getInitialClaudeRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }) + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('keeps explicit host runtime on host', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'host', + localAgentRuntime: 'host', + terminalWindowsShell: 'wsl.exe', + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'host' }) + }) +}) diff --git a/src/main/rate-limits/claude-rate-limit-target.ts b/src/main/rate-limits/claude-rate-limit-target.ts new file mode 100644 index 00000000000..68b33cdd9a8 --- /dev/null +++ b/src/main/rate-limits/claude-rate-limit-target.ts @@ -0,0 +1,71 @@ +import type { GlobalSettings } from '../../shared/types' +import { + getClaudeWslSelectionKey, + normalizeClaudeRuntimeSelection, + type ClaudeAccountSelectionTarget +} from '../claude-accounts/runtime-selection' + +function normalizeOptionalDistro(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function getSingleSelectedWslDistro(settings: GlobalSettings): string | null { + const selection = normalizeClaudeRuntimeSelection(settings) + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length !== 1) { + return null + } + const [distroKey] = selectedWslEntries[0] + return distroKey === getClaudeWslSelectionKey(null) ? null : distroKey +} + +export function getInitialClaudeRateLimitTarget( + settings: GlobalSettings, + platform: NodeJS.Platform = process.platform +): ClaudeAccountSelectionTarget { + if (settings.localAccountRuntime === 'host') { + return { runtime: 'host' } + } + if (settings.localAccountRuntime === 'wsl') { + return { + runtime: 'wsl', + wslDistro: + normalizeOptionalDistro(settings.localAccountWslDistro) ?? + normalizeOptionalDistro(settings.terminalWindowsWslDistro) ?? + getSingleSelectedWslDistro(settings) + } + } + + if ( + settings.localAgentRuntime === 'wsl' || + (settings.localAgentRuntime == null && + platform === 'win32' && + settings.terminalWindowsShell === 'wsl.exe') + ) { + return { + runtime: 'wsl', + wslDistro: + normalizeOptionalDistro(settings.localAgentWslDistro) ?? + normalizeOptionalDistro(settings.terminalWindowsWslDistro) + } + } + + const selection = normalizeClaudeRuntimeSelection(settings) + if (!selection.host) { + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length === 1) { + const [distroKey] = selectedWslEntries[0] + return { + runtime: 'wsl', + wslDistro: distroKey === getClaudeWslSelectionKey(null) ? null : distroKey + } + } + } + + return { runtime: 'host' } +} diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 9a12722303f..77da7f8d36f 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -110,6 +110,23 @@ describe('fetchCodexRateLimits', () => { }) }) + it('does not start the PTY fallback when disabled for background account previews', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + + const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + rpcChild.emit('close') + await vi.advanceTimersByTimeAsync(0) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'codex', + session: null, + weekly: null, + status: 'error' + }) + expect(ptySpawnMock).not.toHaveBeenCalled() + }) + it('normalizes Codex RPC remaining-minute windows to fixed display durations', async () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) @@ -152,4 +169,138 @@ describe('fetchCodexRateLimits', () => { expect(result.session?.windowMinutes).toBe(300) expect(result.weekly?.windowMinutes).toBe(10080) }) + + it('runs rate-limit RPC through WSL when the Codex home is a WSL managed account', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { rateLimits: { primary: { usedPercent: 11 } } } + })}\n` + ) + ) + }, 0) + } + }) + + try { + const resultPromise = fetchCodexRateLimits({ + codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' + }) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + await resultPromise + + expect(childSpawnMock).toHaveBeenCalledWith( + 'wsl.exe', + [ + '-d', + 'Ubuntu', + '--', + 'bash', + '-lc', + "export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'" + ], + expect.objectContaining({ + env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) + }) + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('runs rate-limit PTY fallback through WSL when RPC cannot read usage', async () => { + const originalPlatform = process.platform + const originalCodexHome = process.env.CODEX_HOME + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + process.env.CODEX_HOME = 'C:\\Users\\alice\\.codex' + + const rpcChild = makeRpcChild() + const ptyHandlers: { onData?: (data: string) => void } = {} + childSpawnMock.mockReturnValue(rpcChild) + ptySpawnMock.mockReturnValue({ + onData: vi.fn((callback) => { + ptyHandlers.onData = callback + return makeDisposable() + }), + onExit: vi.fn(() => makeDisposable()), + write: vi.fn(), + kill: vi.fn() + }) + + try { + const resultPromise = fetchCodexRateLimits({ + codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' + }) + rpcChild.emit('close') + await vi.advanceTimersByTimeAsync(0) + + expect(ptySpawnMock).toHaveBeenCalledWith( + 'wsl.exe', + [ + '-d', + 'Ubuntu', + '--', + 'bash', + '-lc', + "export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex " + ], + expect.objectContaining({ + env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) + }) + ) + + const onPtyData = ptyHandlers.onData + if (!onPtyData) { + throw new Error('PTY data handler was not registered') + } + onPtyData('>') + onPtyData('5h limit: 17%\nWeekly limit: 23%\n') + await vi.advanceTimersByTimeAsync(500) + + await expect(resultPromise).resolves.toMatchObject({ + session: { usedPercent: 17 }, + weekly: { usedPercent: 23 }, + status: 'ok' + }) + } finally { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index c7f81df4b40..feec38d98f7 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -7,13 +7,16 @@ import { resolveCodexCommand } from '../codex-cli/command' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup' +import { parseWslUncPath } from '../../shared/wsl-paths' const RPC_TIMEOUT_MS = 10_000 +const WSL_RPC_TIMEOUT_MS = 25_000 const PTY_TIMEOUT_MS = 15_000 const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000 export type FetchCodexRateLimitsOptions = { codexHomePath?: string | null + allowPtyFallback?: boolean } // --------------------------------------------------------------------------- @@ -43,6 +46,37 @@ type RpcRateLimitsResponse = { rateLimits?: RpcRateLimitsResult } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +function buildWslCodexCommand( + codexHomePath: string, + args: string[] +): { + command: string + args: string[] +} | null { + const wslInfo = parseWslUncPath(codexHomePath) + if (process.platform !== 'win32' || !wslInfo) { + return null + } + const script = [ + `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`, + `exec codex ${args.map(shellQuote).join(' ')}` + ].join('; ') + return { + command: 'wsl.exe', + args: ['-d', wslInfo.distro, '--', 'bash', '-lc', script] + } +} + +function cloneProcessEnvWithoutCodexHome(): NodeJS.ProcessEnv { + const env = { ...process.env } + delete env.CODEX_HOME + return env +} + function buildRpcMessage(id: number, method: string, params?: unknown): string { return `${JSON.stringify({ jsonrpc: '2.0', id, method, params: params ?? {} })}\n` } @@ -95,18 +129,21 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { const pty = await import('node-pty') - const codexCommand = resolveCodexCommand() + const wslCodex = options?.codexHomePath ? buildWslCodexCommand(options.codexHomePath, []) : null + const codexCommand = wslCodex ? 'codex' : resolveCodexCommand() // Why: node-pty cannot spawn .cmd/.bat batch scripts directly on Windows — // those need cmd.exe as an interpreter. resolveCodexCommand() may also fall @@ -328,8 +366,8 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise((resolve) => { let output = '' @@ -341,9 +379,9 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise void }[] = [] @@ -450,9 +488,22 @@ export async function fetchCodexRateLimits( if (rpcResult.status === 'ok' || rpcResult.status === 'unavailable') { return rpcResult } + if (options?.allowPtyFallback === false) { + return rpcResult + } // Why: app-server can fail independently of the interactive CLI. Keep the // status-bar useful by trying the older /status PTY reader on RPC errors. } catch { + if (options?.allowPtyFallback === false) { + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'RPC failed', + status: 'error' + } + } // RPC failed — fall through to PTY } diff --git a/src/main/rate-limits/codex-rate-limit-target.test.ts b/src/main/rate-limits/codex-rate-limit-target.test.ts new file mode 100644 index 00000000000..739adc17daa --- /dev/null +++ b/src/main/rate-limits/codex-rate-limit-target.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/types' +import { getInitialCodexRateLimitTarget } from './codex-rate-limit-target' + +function legacySettingsWithoutAccountRuntime(settings: GlobalSettings): GlobalSettings { + const next = { ...settings } as Partial + delete next.localAccountRuntime + return next as GlobalSettings +} + +describe('getInitialCodexRateLimitTarget', () => { + it('uses the configured WSL account runtime before agent detection runtime', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Fedora', + localAgentRuntime: 'host', + terminalWindowsWslDistro: 'Debian' + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Fedora' }) + }) + + it('uses the single selected WSL account distro when account runtime is WSL default', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + activeCodexManagedAccountIdsByRuntime: { + host: 'host-account-1', + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses the configured WSL agent runtime and distro', () => { + expect( + getInitialCodexRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu', + terminalWindowsWslDistro: 'Debian' + }), + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses the Windows WSL terminal setting when agent runtime is implicit', () => { + expect( + getInitialCodexRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }), + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses a single WSL-only active account after restart', () => { + expect( + getInitialCodexRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }) + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('keeps explicit host runtime on host', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'host', + localAgentRuntime: 'host', + terminalWindowsShell: 'wsl.exe', + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'host' }) + }) +}) diff --git a/src/main/rate-limits/codex-rate-limit-target.ts b/src/main/rate-limits/codex-rate-limit-target.ts new file mode 100644 index 00000000000..0afb9d19e47 --- /dev/null +++ b/src/main/rate-limits/codex-rate-limit-target.ts @@ -0,0 +1,73 @@ +import type { GlobalSettings } from '../../shared/types' +import { + getWslSelectionKey, + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from '../codex-accounts/runtime-selection' + +function normalizeOptionalDistro(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function getSingleSelectedWslDistro(settings: GlobalSettings): string | null { + const selection = normalizeCodexRuntimeSelection(settings) + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length !== 1) { + return null + } + const [distroKey] = selectedWslEntries[0] + return distroKey === getWslSelectionKey(null) ? null : distroKey +} + +export function getInitialCodexRateLimitTarget( + settings: GlobalSettings, + platform: NodeJS.Platform = process.platform +): CodexAccountSelectionTarget { + if (settings.localAccountRuntime === 'host') { + return { runtime: 'host' } + } + if (settings.localAccountRuntime === 'wsl') { + return { + runtime: 'wsl', + wslDistro: + normalizeOptionalDistro(settings.localAccountWslDistro) ?? + normalizeOptionalDistro(settings.terminalWindowsWslDistro) ?? + getSingleSelectedWslDistro(settings) + } + } + + if ( + settings.localAgentRuntime === 'wsl' || + (settings.localAgentRuntime == null && + platform === 'win32' && + settings.terminalWindowsShell === 'wsl.exe') + ) { + return { + runtime: 'wsl', + wslDistro: + normalizeOptionalDistro(settings.localAgentWslDistro) ?? + normalizeOptionalDistro(settings.terminalWindowsWslDistro) + } + } + + const selection = normalizeCodexRuntimeSelection(settings) + if (!selection.host) { + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length === 1) { + const [distroKey] = selectedWslEntries[0] + // Why: after restart there is no last-clicked switcher target, but a + // single WSL-only active account is the least surprising quota context. + return { + runtime: 'wsl', + wslDistro: distroKey === getWslSelectionKey(null) ? null : distroKey + } + } + } + + return { runtime: 'host' } +} diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 3c0310569e5..10d5a74c44b 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -230,6 +230,201 @@ describe('RateLimitService', () => { expect(state.opencodeGo?.session?.usedPercent).toBe(40) }) + it('passes the selected WSL Codex home into active account rate-limit fetches', async () => { + const service = new RateLimitService() + const wslCodexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' + const resolver = vi.fn((target) => (target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome)) + service.setCodexHomePathResolver(resolver) + + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refreshForCodexAccountChange(null, { runtime: 'wsl', wslDistro: 'Ubuntu' }) + + expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(fetchCodexRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ codexHomePath: wslCodexHome }) + ) + }) + + it('uses the initialized WSL target for active Codex rate-limit fetches', async () => { + const service = new RateLimitService() + const wslCodexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' + const resolver = vi.fn((target) => (target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome)) + service.setCodexHomePathResolver(resolver) + service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(fetchCodexRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ codexHomePath: wslCodexHome }) + ) + }) + + it('does not fetch host Codex usage when WSL home resolution fails', async () => { + const service = new RateLimitService() + const resolver = vi.fn(() => null) + service.setCodexHomePathResolver(resolver) + service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + + await service.refresh() + + expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(fetchCodexRateLimits).not.toHaveBeenCalled() + expect(service.getState().codex).toMatchObject({ + provider: 'codex', + status: 'error', + error: 'WSL Codex home unavailable for Ubuntu' + }) + }) + + it('uses the initialized WSL target for active Claude rate-limit fetches', async () => { + const service = new RateLimitService() + const resolver = vi.fn(async (target) => ({ + configDir: + target?.runtime === 'wsl' + ? '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude' + : 'C:\\Users\\jin\\.claude', + runtime: target?.runtime ?? 'host', + wslDistro: target?.wslDistro ?? null, + wslLinuxConfigDir: target?.runtime === 'wsl' ? '/home/jin/.claude' : null, + envPatch: target?.runtime === 'wsl' ? { CLAUDE_CONFIG_DIR: '/home/jin/.claude' } : {}, + stripAuthEnv: target?.runtime === 'wsl', + provenance: target?.runtime === 'wsl' ? 'managed:wsl-account:wsl:Ubuntu' : 'system' + })) + service.setClaudeAuthPreparationResolver(resolver) + service.setClaudeFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: expect.objectContaining({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/jin/.claude', + stripAuthEnv: true + }) + }) + expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('does not cache host Codex usage under an outgoing WSL account', async () => { + const service = new RateLimitService() + const wslCodexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' + service.setCodexHomePathResolver((target) => + target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome + ) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits) + .mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + .mockResolvedValueOnce(okProvider('codex', 40, Date.now())) + + await service.refresh() + await service.refreshForCodexAccountChange('wsl-account-1', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(service.getState().inactiveCodexAccounts).not.toEqual( + expect.arrayContaining([expect.objectContaining({ accountId: 'wsl-account-1' })]) + ) + }) + + it('does not cache host Claude usage under an outgoing WSL account', async () => { + const service = new RateLimitService() + service.setInactiveClaudeAccountsResolver(() => [ + { id: 'wsl-account-1', managedAuthPath: '/tmp/account-1/auth' } + ]) + service.setClaudeAuthPreparationResolver(async (target) => ({ + configDir: + target?.runtime === 'wsl' + ? '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude' + : 'C:\\Users\\jin\\.claude', + runtime: target?.runtime ?? 'host', + wslDistro: target?.wslDistro ?? null, + wslLinuxConfigDir: target?.runtime === 'wsl' ? '/home/jin/.claude' : null, + envPatch: {}, + stripAuthEnv: target?.runtime === 'wsl', + provenance: target?.runtime === 'wsl' ? 'managed:wsl-account-1:wsl:Ubuntu' : 'system' + })) + + vi.mocked(fetchClaudeRateLimits) + .mockResolvedValueOnce(okProvider('claude', 20, Date.now())) + .mockResolvedValueOnce(okProvider('claude', 40, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + await service.refreshForClaudeAccountChange('wsl-account-1', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(service.getState().inactiveClaudeAccounts).not.toEqual( + expect.arrayContaining([expect.objectContaining({ accountId: 'wsl-account-1' })]) + ) + }) + + it('passes WSL Codex managed homes into inactive account rate-limit fetches', async () => { + const service = new RateLimitService() + const wslCodexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + service.setInactiveCodexAccountsResolver(() => [ + { id: 'account-1', managedHomePath: wslCodexHome } + ]) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 33, Date.now())) + + await service.fetchInactiveCodexAccountsOnOpen() + + expect(fetchCodexRateLimits).toHaveBeenCalledWith({ + codexHomePath: wslCodexHome, + allowPtyFallback: false + }) + expect(service.getState().inactiveCodexAccounts).toEqual([ + { + accountId: 'account-1', + claude: expect.objectContaining({ + session: expect.objectContaining({ usedPercent: 33 }) + }), + updatedAt: expect.any(Number), + isFetching: false + } + ]) + }) + + it('does not start overlapping inactive Codex preview fetches', async () => { + const service = new RateLimitService() + const accountFetch = deferred() + service.setInactiveCodexAccountsResolver(() => [ + { id: 'account-1', managedHomePath: '/tmp/account-1/home' } + ]) + vi.mocked(fetchCodexRateLimits).mockReturnValueOnce(accountFetch.promise) + + const firstFetch = service.fetchInactiveCodexAccountsOnOpen() + await Promise.resolve() + await service.fetchInactiveCodexAccountsOnOpen() + + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + + accountFetch.resolve(okProvider('codex', 50, Date.now())) + await firstFetch + }) + it('preserves Gemini buckets through getState after fetch', async () => { const service = new RateLimitService() diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 1ef3557e992..82ac7753f75 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -11,14 +11,29 @@ import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetche import type { InactiveClaudeAccountInfo } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { + normalizeClaudeAccountSelectionTarget, + type ClaudeAccountSelectionTarget, + type NormalizedClaudeAccountSelectionTarget +} from '../claude-accounts/runtime-selection' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' +import { + normalizeCodexAccountSelectionTarget, + type CodexAccountSelectionTarget, + type NormalizedCodexAccountSelectionTarget +} from '../codex-accounts/runtime-selection' export type InactiveCodexAccountInfo = { id: string managedHomePath: string } +type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => string | null +type ClaudeAuthPreparationResolver = ( + target?: ClaudeAccountSelectionTarget +) => Promise + // Why: Claude's subscription usage endpoint has a tight request budget. Quota // state is informational, so prefer keeping a recent snapshot over polling it // into 429s during long focused Orca sessions. @@ -57,8 +72,16 @@ export class RateLimitService { private claudeFetchGeneration = 0 private opencodeFetchGeneration = 0 private lastOpencodeConfigHash = '' - private codexHomePathResolver: (() => string | null) | null = null - private claudeAuthPreparationResolver: (() => Promise) | null = null + private codexHomePathResolver: CodexHomePathResolver | null = null + private codexFetchTarget: NormalizedCodexAccountSelectionTarget = { + runtime: 'host', + wslDistro: null + } + private claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null + private claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { + runtime: 'host', + wslDistro: null + } private settingsResolver: | (() => { opencodeSessionCookie: string @@ -86,14 +109,22 @@ export class RateLimitService { } } - setCodexHomePathResolver(resolver: () => string | null): void { + setCodexHomePathResolver(resolver: CodexHomePathResolver): void { this.codexHomePathResolver = resolver } - setClaudeAuthPreparationResolver(resolver: () => Promise): void { + setCodexFetchTarget(target?: CodexAccountSelectionTarget): void { + this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) + } + + setClaudeAuthPreparationResolver(resolver: ClaudeAuthPreparationResolver): void { this.claudeAuthPreparationResolver = resolver } + setClaudeFetchTarget(target?: ClaudeAccountSelectionTarget): void { + this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target) + } + setSettingsResolver( resolver: () => { opencodeSessionCookie: string @@ -152,6 +183,8 @@ export class RateLimitService { getState(): RateLimitState { return { ...this.state, + claudeTarget: this.claudeFetchTarget, + codexTarget: this.codexFetchTarget, inactiveClaudeAccounts: this.buildInactiveArray( this.inactiveClaudeCache, this.inactiveClaudeFetching @@ -172,10 +205,19 @@ export class RateLimitService { return this.getState() } - async refreshForCodexAccountChange(outgoingAccountId?: string | null): Promise { - if (outgoingAccountId && this.state.codex?.session) { + async refreshForCodexAccountChange( + outgoingAccountId?: string | null, + target?: CodexAccountSelectionTarget + ): Promise { + const nextTarget = normalizeCodexAccountSelectionTarget(target) + if ( + outgoingAccountId && + this.state.codex?.session && + this.isSameCodexTarget(this.codexFetchTarget, nextTarget) + ) { this.inactiveCodexCache.set(outgoingAccountId, this.state.codex) } + this.codexFetchTarget = nextTarget this.codexFetchGeneration += 1 this.lastInactiveCodexFetchAt = 0 // Why: switching the selected Codex account must immediately clear the old @@ -189,12 +231,34 @@ export class RateLimitService { return this.getState() } - async refreshForClaudeAccountChange(outgoingAccountId?: string | null): Promise { + async refreshCodexForTarget(target?: CodexAccountSelectionTarget): Promise { + const nextTarget = normalizeCodexAccountSelectionTarget(target) + const targetChanged = !this.isSameCodexTarget(this.codexFetchTarget, nextTarget) + this.codexFetchTarget = nextTarget + this.codexFetchGeneration += 1 + this.updateState({ + ...this.state, + codex: this.withFetchingStatus(targetChanged ? null : this.state.codex, 'codex') + }) + await this.fetchCodexOnly({ force: true }) + return this.getState() + } + + async refreshForClaudeAccountChange( + outgoingAccountId?: string | null, + target?: ClaudeAccountSelectionTarget + ): Promise { + const nextTarget = normalizeClaudeAccountSelectionTarget(target) // Why: snapshot the outgoing account's usage before clearing it so the // inline usage bars in the switcher can show last-known data immediately. - if (outgoingAccountId && this.state.claude?.session) { + if ( + outgoingAccountId && + this.state.claude?.session && + this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) + ) { this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) } + this.claudeFetchTarget = nextTarget this.inactiveClaudeAccountsGeneration += 1 this.pruneInactiveClaudeState() this.claudeFetchGeneration += 1 @@ -207,6 +271,19 @@ export class RateLimitService { return this.getState() } + async refreshClaudeForTarget(target?: ClaudeAccountSelectionTarget): Promise { + const nextTarget = normalizeClaudeAccountSelectionTarget(target) + const targetChanged = !this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) + this.claudeFetchTarget = nextTarget + this.claudeFetchGeneration += 1 + this.updateState({ + ...this.state, + claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude') + }) + await this.fetchClaudeOnly({ force: true }) + return this.getState() + } + async fetchInactiveClaudeAccountsOnOpen(): Promise { if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { return @@ -273,6 +350,9 @@ export class RateLimitService { if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { return } + if (this.inactiveCodexFetching.size > 0) { + return + } const accounts = this.inactiveCodexAccountsResolver?.() ?? [] if (accounts.length === 0) { return @@ -288,7 +368,13 @@ export class RateLimitService { // Why: fetchCodexRateLimits already accepts codexHomePath, so we can // point it at the managed account's home directory directly without // materializing credentials into the shared runtime location. - const fresh = await fetchCodexRateLimits({ codexHomePath: account.managedHomePath }) + // Why: opening the account switcher should never start hidden PTYs for + // every inactive account. On Windows that fallback can crash inside + // ConPTY; RPC-only is enough for this non-critical preview surface. + const fresh = await fetchCodexRateLimits({ + codexHomePath: account.managedHomePath, + allowPtyFallback: false + }) const cached = this.inactiveCodexCache.get(account.id) ?? null this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) } catch { @@ -526,6 +612,50 @@ export class RateLimitService { } } + private isSameCodexTarget( + left: NormalizedCodexAccountSelectionTarget, + right: NormalizedCodexAccountSelectionTarget + ): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro + } + + private isSameClaudeTarget( + left: NormalizedClaudeAccountSelectionTarget, + right: NormalizedClaudeAccountSelectionTarget + ): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro + } + + private getCodexProvenance( + target: NormalizedCodexAccountSelectionTarget, + codexHomePath: string | null + ): string { + const targetKey = target.runtime === 'wsl' ? `wsl:${target.wslDistro ?? '__default__'}` : 'host' + return codexHomePath ? `${targetKey}:managed:${codexHomePath}` : `${targetKey}:system` + } + + private getMissingWslCodexHomeResult( + target: NormalizedCodexAccountSelectionTarget + ): ProviderRateLimits | null { + if (target.runtime !== 'wsl') { + return null + } + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `WSL Codex home unavailable for ${target.wslDistro ?? 'default distro'}`, + status: 'error' + } + } + + private shouldAllowCodexPtyFallback(): boolean { + // Why: quota UI refreshes run in the background. On Windows, hidden PTY + // fallback can crash inside ConPTY, so prefer RPC-only degradation there. + return process.platform !== 'win32' + } + private withFetchingStatus( current: ProviderRateLimits | null, provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' @@ -544,11 +674,13 @@ export class RateLimitService { } private async runFetchAllCycle(): Promise { - const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const claudeTarget = this.claudeFetchTarget + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' const claudeGeneration = this.claudeFetchGeneration - const codexHomePath = this.codexHomePathResolver?.() ?? null - const codexProvenance = codexHomePath ? `managed:${codexHomePath}` : 'system' + const codexTarget = this.codexFetchTarget + const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) const codexGeneration = this.codexFetchGeneration const previousState = this.state const settings = this.settingsResolver?.() @@ -579,9 +711,16 @@ export class RateLimitService { : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go') }) + const missingWslCodexHome = codexHomePath + ? null + : this.getMissingWslCodexHomeResult(codexTarget) const [claudeResult, codexResult, geminiResult, opencodeGoResult] = await Promise.allSettled([ fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }), - fetchCodexRateLimits({ codexHomePath }), + missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback() + }), fetchGeminiRateLimits(geminiCliOAuthEnabled), fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined) ]) @@ -641,14 +780,16 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) - const latestCodexHomePath = this.codexHomePathResolver?.() ?? null - const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' - const latestCodexProvenance = latestCodexHomePath ? `managed:${latestCodexHomePath}` : 'system' + const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) const shouldApplyCodex = codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance const shouldApplyClaude = - claudeGeneration === this.claudeFetchGeneration && claudeProvenance === latestClaudeProvenance + claudeGeneration === this.claudeFetchGeneration && + claudeProvenance === latestClaudeProvenance && + this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration // Why: account switches can race in-flight Codex fetches. Only apply a @@ -675,8 +816,9 @@ export class RateLimitService { } private async runFetchCodexOnlyCycle(): Promise { - const codexHomePath = this.codexHomePathResolver?.() ?? null - const codexProvenance = codexHomePath ? `managed:${codexHomePath}` : 'system' + const codexTarget = this.codexFetchTarget + const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) const codexGeneration = this.codexFetchGeneration const previousState = this.state @@ -685,7 +827,17 @@ export class RateLimitService { codex: this.withFetchingStatus(previousState.codex, 'codex') }) - const codex = await fetchCodexRateLimits({ codexHomePath }).catch( + const missingWslCodexHome = codexHomePath + ? null + : this.getMissingWslCodexHomeResult(codexTarget) + const codex = await ( + missingWslCodexHome + ? Promise.resolve(missingWslCodexHome) + : fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback() + }) + ).catch( (err): ProviderRateLimits => ({ provider: 'codex', session: null, @@ -696,8 +848,8 @@ export class RateLimitService { }) ) - const latestCodexHomePath = this.codexHomePathResolver?.() ?? null - const latestCodexProvenance = latestCodexHomePath ? `managed:${latestCodexHomePath}` : 'system' + const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) const shouldApplyCodex = codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance @@ -710,7 +862,8 @@ export class RateLimitService { } private async runFetchClaudeOnlyCycle(): Promise { - const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const claudeTarget = this.claudeFetchTarget + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' const claudeGeneration = this.claudeFetchGeneration const previousState = this.state @@ -731,10 +884,12 @@ export class RateLimitService { }) ) - const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' const shouldApplyClaude = - claudeGeneration === this.claudeFetchGeneration && claudeProvenance === latestClaudeProvenance + claudeGeneration === this.claudeFetchGeneration && + claudeProvenance === latestClaudeProvenance && + this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) this.updateState({ ...this.state, diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index 1c38564dc1e..a6207bc0234 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -1,4 +1,6 @@ -/* eslint-disable max-lines */ +/* eslint-disable max-lines -- Why: runtime file command tests share mocked fs, + authorization, and watcher lifecycle fixtures; splitting would duplicate the + setup that makes cross-command filesystem behavior comparable. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as Fs from 'fs' import type * as FsPromises from 'fs/promises' diff --git a/src/main/text-generation/commit-message-agent-environment.test.ts b/src/main/text-generation/commit-message-agent-environment.test.ts index 96f50bcd044..b7b91335132 100644 --- a/src/main/text-generation/commit-message-agent-environment.test.ts +++ b/src/main/text-generation/commit-message-agent-environment.test.ts @@ -5,9 +5,13 @@ import { afterEach, describe, expect, it } from 'vitest' import { prepareLocalCommitMessageAgentEnv } from './commit-message-agent-environment' const originalEnv = { ...process.env } +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const tempDirs: string[] = [] afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } for (const key of Object.keys(process.env)) { if (!(key in originalEnv)) { delete process.env[key] @@ -20,6 +24,7 @@ afterEach(() => { }) function makeHome(): string { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) const dir = mkdtempSync(join(tmpdir(), 'orca-commit-env-')) tempDirs.push(dir) process.env.HOME = dir @@ -40,7 +45,7 @@ describe('prepareLocalCommitMessageAgentEnv', () => { expect(result).toEqual({ ok: true, env: expect.objectContaining({ - OPENCODE_CONFIG_DIR: join(home, 'company/opencode') + OPENCODE_CONFIG_DIR: `${home}/company/opencode` }) }) }) @@ -69,7 +74,7 @@ describe('prepareLocalCommitMessageAgentEnv', () => { expect(result).toEqual({ ok: true, env: expect.objectContaining({ - PI_CODING_AGENT_DIR: join(home, '.config/pi-agent') + PI_CODING_AGENT_DIR: `${home}/.config/pi-agent` }) }) }) @@ -104,4 +109,29 @@ describe('prepareLocalCommitMessageAgentEnv', () => { ok: true }) }) + + it('sets CODEX_HOME for host managed Codex accounts', async () => { + const result = await prepareLocalCommitMessageAgentEnv('codex', { + prepareForCodexLaunch: () => + 'C:\\Users\\tester\\AppData\\Roaming\\Orca\\codex-accounts\\a\\home' + }) + + expect(result).toEqual({ + ok: true, + env: expect.objectContaining({ + CODEX_HOME: 'C:\\Users\\tester\\AppData\\Roaming\\Orca\\codex-accounts\\a\\home' + }) + }) + }) + + it('does not pass WSL managed Codex homes to host-local commit generation', async () => { + process.env.CODEX_HOME = 'C:\\Users\\tester\\.codex' + + const result = await prepareLocalCommitMessageAgentEnv('codex', { + prepareForCodexLaunch: () => + '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.local\\share\\orca\\codex-accounts\\a\\home' + }) + + expect(result).toEqual({ ok: true }) + }) }) diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts index 5d4558b5cca..25dbdd8ccc1 100644 --- a/src/main/text-generation/commit-message-agent-environment.ts +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -1,6 +1,7 @@ import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { applyClaudeEnvPatch } from '../claude-accounts/environment' import { readShellStartupEnvVar } from '../pty/shell-startup-env' +import { parseWslUncPath } from '../../shared/wsl-paths' export type CommitMessageAgentEnvironmentResolvers = { prepareForCodexLaunch?: () => string | null @@ -74,6 +75,11 @@ export async function prepareLocalCommitMessageAgentEnv( try { if (agentId === 'codex' && resolvers.prepareForCodexLaunch) { const codexHomePath = resolvers.prepareForCodexLaunch() + if (codexHomePath && parseWslUncPath(codexHomePath)) { + // Why: this local generation path spawns the host Codex binary. A WSL + // managed home is only valid when the process is routed through wsl.exe. + return { ok: true } + } return { ok: true, env: codexHomePath ? { ...cloneProcessEnv(), CODEX_HOME: codexHomePath } : undefined diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index c982d3361a6..4f8f981d852 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -34,13 +34,17 @@ import type { } from '../../shared/mobile-markdown-document' import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types' import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' export function attachMainWindowServices( mainWindow: BrowserWindow, store: Store, runtime: OrcaRuntimeService, - getSelectedCodexHomePath?: () => string | null, - prepareClaudeAuth?: () => Promise, + getSelectedCodexHomePath?: (target?: CodexAccountSelectionTarget) => string | null, + prepareClaudeAuth?: ( + target?: ClaudeAccountSelectionTarget + ) => Promise, options?: { onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void } diff --git a/src/main/wsl-bash-command.test.ts b/src/main/wsl-bash-command.test.ts new file mode 100644 index 00000000000..a6440a7fd8d --- /dev/null +++ b/src/main/wsl-bash-command.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { buildEncodedWslBashCommand } from './wsl-bash-command' + +describe('buildEncodedWslBashCommand', () => { + it('wraps Bash scripts without exposing local shell variables to wsl.exe', () => { + const command = [ + 'set -euo pipefail', + "candidate='/home/alice/.local/share/orca/codex-accounts/a/home'", + 'candidate_real=$(readlink -f -- "$candidate")', + 'printf "%s\\n" "$candidate_real"' + ].join('\n') + + const wrapped = buildEncodedWslBashCommand(command) + const encoded = wrapped.match( + /^set -o pipefail; printf %s '([^']+)' \| base64 -d \| bash$/ + )?.[1] + + expect(wrapped).not.toContain('$candidate') + expect(wrapped).not.toContain('\n') + expect(encoded).toBeTruthy() + expect(Buffer.from(encoded as string, 'base64').toString('utf8')).toBe(command) + }) +}) diff --git a/src/main/wsl-bash-command.ts b/src/main/wsl-bash-command.ts new file mode 100644 index 00000000000..ff7426ae6ec --- /dev/null +++ b/src/main/wsl-bash-command.ts @@ -0,0 +1,10 @@ +function quoteShell(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +export function buildEncodedWslBashCommand(command: string): string { + // Why: wsl.exe preprocesses `$local_shell_vars` in command arguments before + // Bash sees them. Base64 keeps validation scripts intact across that boundary. + const encoded = Buffer.from(command, 'utf8').toString('base64') + return `set -o pipefail; printf %s ${quoteShell(encoded)} | base64 -d | bash` +} diff --git a/src/main/wsl-env.ts b/src/main/wsl-env.ts new file mode 100644 index 00000000000..71e836e945b --- /dev/null +++ b/src/main/wsl-env.ts @@ -0,0 +1,17 @@ +export function addWslEnvKeys( + env: Record, + keys: readonly string[] +): void { + const existing = env.WSLENV ?? process.env.WSLENV ?? '' + const tokens = existing.split(':').filter(Boolean) + const tokenNames = new Set(tokens.map((token) => token.split('/')[0])) + + for (const key of keys) { + if (!tokenNames.has(key)) { + tokens.push(key) + tokenNames.add(key) + } + } + + env.WSLENV = tokens.join(':') +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 8f458b78e94..4a3c56dac8f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -224,7 +224,7 @@ import type { ClaudeUsageSessionRow, ClaudeUsageSummary } from '../shared/claude-usage-types' -import type { RateLimitState } from '../shared/rate-limit-types' +import type { RateLimitRuntimeTarget, RateLimitState } from '../shared/rate-limit-types' import type { SpeechErrorEvent, SpeechLifecycleEvent, @@ -424,9 +424,16 @@ export type RefreshAgentsResult = { } export type PreflightApi = { - check: (args?: { force?: boolean; wslDistro?: string | null }) => Promise - detectAgents: (args?: { wslDistro?: string | null }) => Promise - refreshAgents: (args?: { wslDistro?: string | null }) => Promise + check: (args?: { + force?: boolean + wslDistro?: string | null + wslDefault?: boolean + }) => Promise + detectAgents: (args?: { wslDistro?: string | null; wslDefault?: boolean }) => Promise + refreshAgents: (args?: { + wslDistro?: string | null + wslDefault?: boolean + }) => Promise detectRemoteAgents: (args: { connectionId: string }) => Promise } @@ -1329,17 +1336,31 @@ export type PreloadApi = { } codexAccounts: { list: () => Promise - add: () => Promise + add: (args?: { + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise reauthenticate: (args: { accountId: string }) => Promise remove: (args: { accountId: string }) => Promise - select: (args: { accountId: string | null }) => Promise + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise } claudeAccounts: { list: () => Promise - add: () => Promise + add: (args?: { + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise reauthenticate: (args: { accountId: string }) => Promise remove: (args: { accountId: string }) => Promise - select: (args: { accountId: string | null }) => Promise + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise } cli: { getInstallStatus: () => Promise @@ -2017,6 +2038,8 @@ export type PreloadApi = { rateLimits: { get: () => Promise refresh: () => Promise + refreshCodexForTarget: (target: RateLimitRuntimeTarget) => Promise + refreshClaudeForTarget: (target: RateLimitRuntimeTarget) => Promise setPollingInterval: (ms: number) => Promise fetchInactiveClaudeAccounts: () => Promise fetchInactiveCodexAccounts: () => Promise @@ -2101,6 +2124,7 @@ export type PreloadApi = { } wsl: { isAvailable: () => Promise + listDistros: () => Promise } pwsh: { isAvailable: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index f8b7a9f3bb6..9a0e8a51589 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -55,7 +55,7 @@ import type { RuntimeMobileMarkdownRequest, RuntimeMobileMarkdownResponse } from '../shared/mobile-markdown-document' -import type { RateLimitState } from '../shared/rate-limit-types' +import type { RateLimitRuntimeTarget, RateLimitState } from '../shared/rate-limit-types' import type { WorkspaceSpaceAnalyzeResult, WorkspaceSpaceScanProgress @@ -461,7 +461,8 @@ const api = { }, wsl: { - isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable') + isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable'), + listDistros: (): Promise => ipcRenderer.invoke('wsl:listDistros') }, pwsh: { @@ -1412,24 +1413,32 @@ const api = { codexAccounts: { list: (): Promise => ipcRenderer.invoke('codexAccounts:list'), - add: (): Promise => ipcRenderer.invoke('codexAccounts:add'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + ipcRenderer.invoke('codexAccounts:add', args), reauthenticate: (args: { accountId: string }): Promise => ipcRenderer.invoke('codexAccounts:reauthenticate', args), remove: (args: { accountId: string }): Promise => ipcRenderer.invoke('codexAccounts:remove', args), - select: (args: { accountId: string | null }): Promise => - ipcRenderer.invoke('codexAccounts:select', args) + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }): Promise => ipcRenderer.invoke('codexAccounts:select', args) }, claudeAccounts: { list: (): Promise => ipcRenderer.invoke('claudeAccounts:list'), - add: (): Promise => ipcRenderer.invoke('claudeAccounts:add'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + ipcRenderer.invoke('claudeAccounts:add', args), reauthenticate: (args: { accountId: string }): Promise => ipcRenderer.invoke('claudeAccounts:reauthenticate', args), remove: (args: { accountId: string }): Promise => ipcRenderer.invoke('claudeAccounts:remove', args), - select: (args: { accountId: string | null }): Promise => - ipcRenderer.invoke('claudeAccounts:select', args) + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }): Promise => ipcRenderer.invoke('claudeAccounts:select', args) }, cli: { @@ -1495,10 +1504,12 @@ const api = { } linear: { connected: boolean } }> => ipcRenderer.invoke('preflight:check', args), - detectAgents: (args?: { wslDistro?: string | null }): Promise => + detectAgents: (args?: { wslDistro?: string | null; wslDefault?: boolean }): Promise => ipcRenderer.invoke('preflight:detectAgents', args), - refreshAgents: (args?: { wslDistro?: string | null }): Promise => - ipcRenderer.invoke('preflight:refreshAgents', args), + refreshAgents: (args?: { + wslDistro?: string | null + wslDefault?: boolean + }): Promise => ipcRenderer.invoke('preflight:refreshAgents', args), detectRemoteAgents: (args: { connectionId: string }): Promise => ipcRenderer.invoke('preflight:detectRemoteAgents', args) }, @@ -3056,6 +3067,10 @@ const api = { rateLimits: { get: (): Promise => ipcRenderer.invoke('rateLimits:get'), refresh: (): Promise => ipcRenderer.invoke('rateLimits:refresh'), + refreshCodexForTarget: (target: RateLimitRuntimeTarget): Promise => + ipcRenderer.invoke('rateLimits:refreshCodexForTarget', target), + refreshClaudeForTarget: (target: RateLimitRuntimeTarget): Promise => + ipcRenderer.invoke('rateLimits:refreshClaudeForTarget', target), setPollingInterval: (ms: number): Promise => ipcRenderer.invoke('rateLimits:setPollingInterval', ms), fetchInactiveClaudeAccounts: (): Promise => diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 21518e22605..cc86de89f63 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -14,6 +14,7 @@ import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' import { Separator } from '../ui/separator' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react' import { useAppStore } from '../../store' import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from '../status-bar/icons' @@ -22,13 +23,14 @@ import { ACCOUNTS_CLAUDE_SEARCH_ENTRIES, ACCOUNTS_CODEX_SEARCH_ENTRIES, ACCOUNTS_GEMINI_SEARCH_ENTRIES, + ACCOUNTS_LOCATION_SEARCH_ENTRIES, ACCOUNTS_OPENCODE_SEARCH_ENTRIES, ACCOUNTS_PANE_SEARCH_ENTRIES } from './accounts-search' import { SearchableSetting } from './SearchableSetting' +import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' import { matchesSettingsSearch } from './settings-search' import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' -import { getLocalPreflightContext } from '@/lib/local-preflight-context' import { Dialog, DialogContent, @@ -43,6 +45,13 @@ export { ACCOUNTS_PANE_SEARCH_ENTRIES } type AccountsPaneProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void + wslAvailable?: boolean + wslDistros?: string[] + wslCapabilitiesLoading?: boolean +} + +function getHostRuntimeLabel(): string { + return navigator.userAgent.includes('Windows') ? 'Windows' : 'This device' } function getCodexAccountLabel( @@ -55,6 +64,46 @@ function getCodexAccountLabel( return state.accounts.find((account) => account.id === accountId)?.email ?? 'Codex account' } +function getActiveCodexAccountIdForRuntime( + state: CodexRateLimitAccountsState, + runtime: LocalAccountRuntime +): string | null { + if (runtime.runtime === 'host') { + return state.activeAccountIdsByRuntime?.host ?? state.activeAccountId + } + if (runtime.wslDistro) { + return state.activeAccountIdsByRuntime?.wsl?.[runtime.wslDistro] ?? null + } + const defaultSelection = state.activeAccountIdsByRuntime?.wsl?.__default__ + if (defaultSelection) { + return defaultSelection + } + const selectedIds = Array.from( + new Set(Object.values(state.activeAccountIdsByRuntime?.wsl ?? {}).filter(Boolean)) + ) + return selectedIds.length === 1 ? selectedIds[0] : null +} + +function getActiveClaudeAccountIdForRuntime( + state: ClaudeRateLimitAccountsState, + runtime: LocalAccountRuntime +): string | null { + if (runtime.runtime === 'host') { + return state.activeAccountIdsByRuntime?.host ?? state.activeAccountId + } + if (runtime.wslDistro) { + return state.activeAccountIdsByRuntime?.wsl?.[runtime.wslDistro] ?? null + } + const defaultSelection = state.activeAccountIdsByRuntime?.wsl?.__default__ + if (defaultSelection) { + return defaultSelection + } + const selectedIds = Array.from( + new Set(Object.values(state.activeAccountIdsByRuntime?.wsl ?? {}).filter(Boolean)) + ) + return selectedIds.length === 1 ? selectedIds[0] : null +} + function getClaudeAccountLabel( state: ClaudeRateLimitAccountsState, accountId: string | null | undefined @@ -65,6 +114,24 @@ function getClaudeAccountLabel( return state.accounts.find((account) => account.id === accountId)?.email ?? 'Claude account' } +function getCodexAccountRuntimeLabel( + account: CodexRateLimitAccountsState['accounts'][number] +): string { + if (account.managedHomeRuntime === 'wsl') { + return account.wslDistro ? `WSL ${account.wslDistro}` : 'WSL' + } + return getHostRuntimeLabel() +} + +function getClaudeAccountRuntimeLabel( + account: ClaudeRateLimitAccountsState['accounts'][number] +): string { + if (account.managedAuthRuntime === 'wsl') { + return account.wslDistro ? `WSL ${account.wslDistro}` : 'WSL' + } + return getHostRuntimeLabel() +} + function getCodexAccountErrorDescription(error: unknown): string { const message = String((error as Error)?.message ?? error) .replace(/^Error occurred in handler for 'codexAccounts:[^']+':\s*/i, '') @@ -109,30 +176,102 @@ function getClaudeAccountErrorDescription(error: unknown): string { ) } -export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): React.JSX.Element { +type LocalAccountRuntime = { + runtime: 'host' | 'wsl' + wslDistro?: string | null + label: string +} + +function accountMatchesRuntime( + account: + | CodexRateLimitAccountsState['accounts'][number] + | ClaudeRateLimitAccountsState['accounts'][number], + runtime: LocalAccountRuntime +): boolean { + const accountRuntime = + 'authMethod' in account + ? (account.managedAuthRuntime ?? 'host') + : (account.managedHomeRuntime ?? 'host') + const accountDistro = account.wslDistro ?? null + if (runtime.runtime === 'host') { + return accountRuntime !== 'wsl' + } + if (accountRuntime !== 'wsl') { + return false + } + return runtime.wslDistro ? accountDistro === runtime.wslDistro : true +} + +function getSelectedAccountRuntime( + settings: GlobalSettings, + wslAvailable: boolean, + wslDistros: string[], + wslCapabilitiesLoading: boolean +): LocalAccountRuntime { + if (settings.localAccountRuntime === 'wsl') { + if (!wslAvailable && !wslCapabilitiesLoading) { + return { runtime: 'wsl', label: 'WSL' } + } + const configuredDistro = settings.localAccountWslDistro?.trim() || null + const selectedDistro = + configuredDistro && (wslCapabilitiesLoading || wslDistros.includes(configuredDistro)) + ? configuredDistro + : null + return { + runtime: 'wsl', + wslDistro: selectedDistro, + label: selectedDistro ? `WSL ${selectedDistro}` : 'WSL default' + } + } + return { runtime: 'host', label: getHostRuntimeLabel() } +} + +export function AccountsPane({ + settings, + updateSettings, + wslAvailable = false, + wslDistros = [], + wslCapabilitiesLoading = false +}: AccountsPaneProps): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const fetchSettings = useAppStore((s) => s.fetchSettings) - const localPreflightContext = useAppStore(getLocalPreflightContext) - const activeWslDistro = localPreflightContext?.wslDistro?.trim() || null const recordedOpenCodeSettingEditsRef = useRef>(new Set()) + const accountRuntime = getSelectedAccountRuntime( + settings, + wslAvailable, + wslDistros, + wslCapabilitiesLoading + ) const [codexAccounts, setCodexAccounts] = useState({ accounts: [], - activeAccountId: null + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } }) const [codexAction, setCodexAction] = useState< 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}` >('idle') const [claudeAccounts, setClaudeAccounts] = useState({ accounts: [], - activeAccountId: null + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } }) const [claudeAction, setClaudeAction] = useState< 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}` >('idle') const [removeAccountId, setRemoveAccountId] = useState(null) const [removeClaudeAccountId, setRemoveClaudeAccountId] = useState(null) + const visibleClaudeAccounts = claudeAccounts.accounts.filter((account) => + accountMatchesRuntime(account, accountRuntime) + ) + const visibleCodexAccounts = codexAccounts.accounts.filter((account) => + accountMatchesRuntime(account, accountRuntime) + ) + const activeCodexAccountId = getActiveCodexAccountIdForRuntime(codexAccounts, accountRuntime) + const activeClaudeAccountId = getActiveClaudeAccountIdForRuntime(claudeAccounts, accountRuntime) + const accountRuntimeUnavailable = + accountRuntime.runtime === 'wsl' && !wslAvailable && !wslCapabilitiesLoading const recordOpenCodeSettingEdit = (field: 'cookie' | 'workspaceId'): void => { if (recordedOpenCodeSettingEditsRef.current.has(field)) { @@ -202,27 +341,90 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R }) } + const accountRuntimeControls = ( + + + updateSettings({ localAccountRuntime: value })} + equalWidth + options={[ + { value: 'host', label: getHostRuntimeLabel() }, + { + value: 'wsl', + label: 'WSL', + disabled: wslCapabilitiesLoading || !wslAvailable + } + ]} + /> + {accountRuntime.runtime === 'wsl' ? ( + + ) : null} +
+ } + /> + + ) + const runCodexAccountAction = async ( action: typeof codexAction, operation: () => Promise ): Promise => { - const previousActiveAccountId = codexAccounts.activeAccountId + const previousActiveAccountId = getActiveCodexAccountIdForRuntime(codexAccounts, accountRuntime) setCodexAction(action) try { const next = await operation() await syncCodexAccounts(next) recordFeatureInteraction('codex-account-switching') + const nextActiveAccountId = getActiveCodexAccountIdForRuntime(next, accountRuntime) const shouldPromptRestart = action === 'adding' || - (action.startsWith('select:') && previousActiveAccountId !== next.activeAccountId) || + (action.startsWith('select:') && previousActiveAccountId !== nextActiveAccountId) || (action.startsWith('reauth:') && - next.activeAccountId !== null && - action === `reauth:${next.activeAccountId}`) || - (action.startsWith('remove:') && previousActiveAccountId !== next.activeAccountId) + nextActiveAccountId !== null && + action === `reauth:${nextActiveAccountId}`) || + (action.startsWith('remove:') && previousActiveAccountId !== nextActiveAccountId) if (shouldPromptRestart) { void markLiveCodexSessionsForRestart({ previousAccountLabel: getCodexAccountLabel(codexAccounts, previousActiveAccountId), - nextAccountLabel: getCodexAccountLabel(next, next.activeAccountId) + nextAccountLabel: getCodexAccountLabel(next, nextActiveAccountId) }) } } catch (error) { @@ -238,15 +440,25 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R action: typeof claudeAction, operation: () => Promise ): Promise => { - const previousActiveAccountId = claudeAccounts.activeAccountId + const previousActiveAccountId = getActiveClaudeAccountIdForRuntime( + claudeAccounts, + accountRuntime + ) setClaudeAction(action) try { const next = await operation() await syncClaudeAccounts(next) recordFeatureInteraction('claude-account-switching') - if (previousActiveAccountId !== next.activeAccountId || action === 'adding') { + const nextActiveAccountId = getActiveClaudeAccountIdForRuntime(next, accountRuntime) + const shouldPromptRestart = + action === 'adding' || + previousActiveAccountId !== nextActiveAccountId || + (action.startsWith('reauth:') && + nextActiveAccountId !== null && + action === `reauth:${nextActiveAccountId}`) + if (shouldPromptRestart) { toast.info('Claude account updated.', { - description: `${getClaudeAccountLabel(claudeAccounts, previousActiveAccountId)} → ${getClaudeAccountLabel(next, next.activeAccountId)}. Restart live Claude terminals before continuing old sessions.` + description: `${getClaudeAccountLabel(claudeAccounts, previousActiveAccountId)} -> ${getClaudeAccountLabel(next, nextActiveAccountId)}. Restart live Claude terminals before continuing old sessions.` }) } } catch (error) { @@ -259,6 +471,11 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R } const visibleSections = [ + matchesSettingsSearch(searchQuery, ACCOUNTS_LOCATION_SEARCH_ENTRIES) ? ( +
+ {accountRuntimeControls} +
+ ) : null, matchesSettingsSearch(searchQuery, ACCOUNTS_CLAUDE_SEARCH_ENTRIES) ? (
@@ -282,16 +499,23 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R

- Orca swaps Claude auth only; config and chat history stay in the shared Claude root. + Showing {accountRuntime.label} accounts. New accounts are added there.

- {claudeAccounts.accounts.length === 0 ? ( + {visibleClaudeAccounts.length === 0 ? (
- No managed Claude accounts yet. Orca will use your system default Claude login until - you add one here. + No managed Claude accounts for {accountRuntime.label}. Orca will use that + environment's system default Claude login until you add one here.
) : ( - claudeAccounts.accounts.map((account) => { - const isActive = claudeAccounts.activeAccountId === account.id + visibleClaudeAccounts.map((account) => { + const isActive = activeClaudeAccountId === account.id const isReauthing = claudeAction === `reauth:${account.id}` - const isBusy = claudeAction !== 'idle' + const isBusy = claudeAction !== 'idle' || accountRuntimeUnavailable return (
void runClaudeAccountAction(`select:${account.id}`, () => - window.api.claudeAccounts.select({ accountId: account.id }) + window.api.claudeAccounts.select({ + accountId: account.id, + runtime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null + }) ) } disabled={isBusy} @@ -368,6 +600,12 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R >
{account.email} + + {getClaudeAccountRuntimeLabel(account)} + {isActive ? ( - {activeWslDistro ? ( -

- WSL terminals use the Codex login inside {activeWslDistro}. Managed Codex account - switching applies to host terminals. -

- ) : null}

Each account keeps its own local sign-in context in Orca. Account auth stays on this device. @@ -472,18 +704,23 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R

- {activeWslDistro - ? `Use codex login in ${activeWslDistro} to change the WSL Codex account.` - : 'Add a Codex account to use it in Orca.'} + Showing {accountRuntime.label} accounts. New accounts are added there.

- {codexAccounts.accounts.length === 0 ? ( -
- {activeWslDistro - ? `No managed host Codex accounts yet. WSL terminals will use the Codex login in ${activeWslDistro}.` - : 'No managed Codex accounts yet. Orca will use your system default Codex login until you add one here.'} -
- ) : ( -
- - {codexAccounts.accounts.map((account) => { - const isActive = codexAccounts.activeAccountId === account.id + + Use your current {accountRuntime.label} Codex login. + +
+ + {visibleCodexAccounts.length === 0 ? ( +
+ No managed Codex accounts for {accountRuntime.label}. Orca will use that + environment's system default Codex login until you add one here. +
+ ) : ( + visibleCodexAccounts.map((account) => { + const isActive = activeCodexAccountId === account.id const isReauthing = codexAction === `reauth:${account.id}` const isRemoving = codexAction === `remove:${account.id}` - const isBusy = codexAction !== 'idle' + const isBusy = codexAction !== 'idle' || accountRuntimeUnavailable return (
void runCodexAccountAction(`select:${account.id}`, () => - window.api.codexAccounts.select({ accountId: account.id }) + window.api.codexAccounts.select({ + accountId: account.id, + runtime: account.managedHomeRuntime ?? 'host', + wslDistro: account.wslDistro ?? null + }) ) } disabled={isBusy} @@ -562,6 +806,12 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R >
{account.email} + + {getCodexAccountRuntimeLabel(account)} + {isActive ? (
) - })} -
- )} + }) + )} +
) : null, @@ -662,8 +912,8 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R

Extracts OAuth credentials from your local Gemini CLI installation to authenticate - with Google. This uses credentials issued to the Gemini CLI app, not Orca. May break - if Google updates the CLI. Use at your own risk. + with Google for {accountRuntime.label}. This uses credentials issued to the Gemini CLI + app, not Orca. May break if Google updates the CLI. Use at your own risk.

+ ) + })} + + + ) +} + function ClaudeSwitcherMenu({ claude, compact, @@ -122,22 +582,30 @@ function ClaudeSwitcherMenu({ const [accountsExpanded, setAccountsExpanded] = useState(false) const [accounts, setAccounts] = useState({ accounts: [], - activeAccountId: null + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } }) const [isSwitching, setIsSwitching] = useState(false) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const fetchSettings = useAppStore((s) => s.fetchSettings) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const refreshClaudeRateLimitsForTarget = useAppStore((s) => s.refreshClaudeRateLimitsForTarget) const fetchInactiveClaudeAccountUsage = useAppStore((s) => s.fetchInactiveClaudeAccountUsage) const inactiveClaudeAccounts = useAppStore((s) => s.rateLimits.inactiveClaudeAccounts) + const claudeTarget = useAppStore((s) => s.rateLimits.claudeTarget) + const settings = useAppStore((s) => s.settings) + const windowsTerminalCapabilities = useWindowsTerminalCapabilities( + navigator.userAgent.includes('Windows') + ) const claudeAccountSyncKey = useAppStore((s) => { const settings = s.settings if (!settings) { return 'no-settings' } - return `${settings.activeClaudeManagedAccountId ?? 'system'}:${settings.claudeManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` + return `${settings.activeClaudeManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeClaudeManagedAccountIdsByRuntime ?? null)}:${settings.claudeManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` }) + const accountState = getClaudeStatusAccountsFromSettings(settings) ?? accounts const loadAccounts = useCallback(async () => { const next = await window.api.claudeAccounts.list() @@ -163,13 +631,20 @@ function ClaudeSwitcherMenu({ } }, [accountsExpanded, fetchInactiveClaudeAccountUsage]) - const handleSelectAccount = async (accountId: string | null): Promise => { + const handleSelectAccount = async ( + accountId: string | null, + target: CodexStatusRuntimeTarget + ): Promise => { if (isSwitching) { return } setIsSwitching(true) try { - const next = await window.api.claudeAccounts.select({ accountId }) + const next = await window.api.claudeAccounts.select({ + accountId, + runtime: target.runtime, + wslDistro: target.wslDistro + }) recordFeatureInteraction('claude-account-switching') setAccounts(next) await fetchSettings() @@ -181,19 +656,39 @@ function ClaudeSwitcherMenu({ } } - const activeAccountLabel = - accounts.activeAccountId === null - ? 'System default' - : (accounts.accounts.find((account) => account.id === accounts.activeAccountId)?.email ?? - 'Managed') - const availableSwitchTargets = [ - ...(accounts.activeAccountId === null - ? [] - : [{ id: null as string | null, label: 'System default' }]), - ...accounts.accounts - .filter((account) => account.id !== accounts.activeAccountId) - .map((account) => ({ id: account.id, label: account.email })) - ] + const handleSelectRuntime = async (group: ClaudeStatusSwitchGroup): Promise => { + const currentKey = getCodexStatusRuntimeKey( + normalizeClaudeStatusRuntimeTarget(accountState, toCodexStatusRuntimeTarget(claudeTarget)) + ) + if (group.key === currentKey) { + return + } + setAccountsExpanded(false) + try { + await refreshClaudeRateLimitsForTarget(group.runtimeTarget) + } catch (error) { + console.error('Failed to switch Claude usage runtime:', error) + } + } + + const selectedRuntimeKey = getCodexStatusRuntimeKey( + normalizeClaudeStatusRuntimeTarget(accountState, toCodexStatusRuntimeTarget(claudeTarget)) + ) + const fallbackWslDistro = getStatusBarPreferredWslDistro( + settings, + windowsTerminalCapabilities.wslDistros + ) + const switchGroups = buildClaudeStatusSwitchGroups( + accountState, + toCodexStatusRuntimeTarget(claudeTarget), + { + fallbackWslDistro, + includeFallbackWsl: shouldIncludeSettingsWslRuntime(settings) + } + ) + const selectedGroup = + switchGroups.find((group) => group.key === selectedRuntimeKey) ?? switchGroups[0] + const activeTarget = selectedGroup?.targets.find((target) => target.active) return ( void handleSelectRuntime(group)} + ariaLabel="Claude usage runtime" + /> + } open={open} onOpenChange={handleOpenChange} > @@ -212,7 +715,7 @@ function ClaudeSwitcherMenu({ }} > - {activeAccountLabel} + {activeTarget?.label ?? 'System default'} {accountsExpanded ? ( @@ -226,25 +729,34 @@ function ClaudeSwitcherMenu({ Switch to
- {availableSwitchTargets.length === 0 ? ( + {selectedGroup?.targets.length === 0 ? (
No other accounts
) : null} - {availableSwitchTargets.map((target) => { + {selectedGroup?.targets.map((target) => { const inactiveUsage = target.id ? inactiveClaudeAccounts.find((a) => a.accountId === target.id) : null return ( { event.preventDefault() - void handleSelectAccount(target.id) + if (!target.active) { + void handleSelectAccount(target.id, target.runtimeTarget) + } }} >
- {target.label} +
+ {target.label} + {target.active ? ( + + Active + + ) : null} +
{inactiveUsage?.isFetching && !inactiveUsage.claude ? ( ) : inactiveUsage?.claude ? ( @@ -487,15 +999,22 @@ function CodexSwitcherMenu({ const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const fetchSettings = useAppStore((s) => s.fetchSettings) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const refreshCodexRateLimitsForTarget = useAppStore((s) => s.refreshCodexRateLimitsForTarget) const fetchInactiveCodexAccountUsage = useAppStore((s) => s.fetchInactiveCodexAccountUsage) const inactiveCodexAccounts = useAppStore((s) => s.rateLimits.inactiveCodexAccounts) + const codexTarget = useAppStore((s) => s.rateLimits.codexTarget) + const settings = useAppStore((s) => s.settings) + const windowsTerminalCapabilities = useWindowsTerminalCapabilities( + navigator.userAgent.includes('Windows') + ) const codexAccountSyncKey = useAppStore((s) => { const settings = s.settings if (!settings) { return 'no-settings' } - return `${settings.activeCodexManagedAccountId ?? 'system'}:${settings.codexManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` + return `${settings.activeCodexManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeCodexManagedAccountIdsByRuntime ?? null)}:${settings.codexManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` }) + const accountState = getCodexStatusAccountsFromSettings(settings) ?? accounts const loadAccounts = useCallback(async () => { const next = await window.api.codexAccounts.list() @@ -512,21 +1031,29 @@ function CodexSwitcherMenu({ }) }, [loadAccounts, open, codexAccountSyncKey]) - const handleSelectAccount = async (accountId: string | null): Promise => { + const handleSelectAccount = async ( + accountId: string | null, + target: CodexStatusRuntimeTarget + ): Promise => { if (isSwitching) { return } - const previousActiveAccountId = accounts.activeAccountId + const previousActiveAccountId = getCodexStatusActiveId(accountState, target) setIsSwitching(true) try { - const next = await window.api.codexAccounts.select({ accountId }) + const next = await window.api.codexAccounts.select({ + accountId, + runtime: target.runtime, + wslDistro: target.wslDistro + }) recordFeatureInteraction('codex-account-switching') setAccounts(next) await fetchSettings() - if (previousActiveAccountId !== next.activeAccountId) { + const nextActiveAccountId = getCodexStatusActiveId(next, target) + if (previousActiveAccountId !== nextActiveAccountId) { await markLiveCodexSessionsForRestart({ - previousAccountLabel: getCodexAccountLabel(accounts, previousActiveAccountId), - nextAccountLabel: getCodexAccountLabel(next, next.activeAccountId) + previousAccountLabel: getCodexAccountLabel(accountState, previousActiveAccountId), + nextAccountLabel: getCodexAccountLabel(next, nextActiveAccountId) }) // Why: account switching can require a second explicit recovery step // for live Codex terminals. Keeping the switcher open and collapsing @@ -541,6 +1068,21 @@ function CodexSwitcherMenu({ } } + const handleSelectRuntime = async (group: CodexStatusSwitchGroup): Promise => { + const currentKey = getCodexStatusRuntimeKey( + normalizeCodexStatusRuntimeTarget(accountState, toCodexStatusRuntimeTarget(codexTarget)) + ) + if (group.key === currentKey) { + return + } + setAccountsExpanded(false) + try { + await refreshCodexRateLimitsForTarget(group.runtimeTarget) + } catch (error) { + console.error('Failed to switch Codex usage runtime:', error) + } + } + const handleOpenChange = useCallback((nextOpen: boolean): void => { setOpen(nextOpen) if (!nextOpen) { @@ -554,24 +1096,24 @@ function CodexSwitcherMenu({ } }, [accountsExpanded, fetchInactiveCodexAccountUsage]) - const activeAccountLabel = - accounts.activeAccountId === null - ? 'System default' - : (accounts.accounts.find((account) => account.id === accounts.activeAccountId)?.email ?? - 'Managed') - const availableSwitchTargets = [ - ...(accounts.activeAccountId === null - ? [] - : [{ id: null as string | null, label: 'System default' }]), - ...accounts.accounts - .filter((account) => account.id !== accounts.activeAccountId) - .map((account) => ({ - id: account.id, - label: account.workspaceLabel - ? `${account.email} (${account.workspaceLabel})` - : account.email - })) - ] + const selectedRuntimeKey = getCodexStatusRuntimeKey( + normalizeCodexStatusRuntimeTarget(accountState, toCodexStatusRuntimeTarget(codexTarget)) + ) + const fallbackWslDistro = getStatusBarPreferredWslDistro( + settings, + windowsTerminalCapabilities.wslDistros + ) + const switchGroups = buildCodexStatusSwitchGroups( + accountState, + toCodexStatusRuntimeTarget(codexTarget), + { + fallbackWslDistro, + includeFallbackWsl: shouldIncludeSettingsWslRuntime(settings) + } + ) + const selectedGroup = + switchGroups.find((group) => group.key === selectedRuntimeKey) ?? switchGroups[0] + const activeTarget = selectedGroup?.targets.find((target) => target.active) return ( void handleSelectRuntime(group)} + ariaLabel="Codex usage runtime" + /> + } open={open} onOpenChange={handleOpenChange} > @@ -589,9 +1139,13 @@ function CodexSwitcherMenu({ setAccountsExpanded((prev) => !prev) }} > - - {activeAccountLabel} - +
+
+ + {activeTarget?.label ?? 'System default'} + +
+
{accountsExpanded ? ( ) : ( @@ -600,45 +1154,52 @@ function CodexSwitcherMenu({ {accountsExpanded ? (
-
- Switch to -
- {availableSwitchTargets.length === 0 ? ( -
No other accounts
- ) : null} - {availableSwitchTargets.map((target) => { - const inactiveUsage = target.id - ? inactiveCodexAccounts.find((a) => a.accountId === target.id) - : null + {selectedGroup ? ( + <> + {selectedGroup.targets.map((target) => { + const inactiveUsage = target.id + ? inactiveCodexAccounts.find((a) => a.accountId === target.id) + : null - return ( - { - // Why: account switching may need an immediate follow-up - // restart action for live Codex tabs. Prevent the menu from - // auto-closing so that prompt can stay within the same - // account-switcher interaction instead of jumping elsewhere. - event.preventDefault() - void handleSelectAccount(target.id) - }} - disabled={isSwitching} - > -
- {target.label} - {inactiveUsage?.isFetching && !inactiveUsage.claude ? ( - - ) : inactiveUsage?.claude ? ( - - ) : null} -
-
- ) - })} + return ( + { + // Why: account switching may need an immediate follow-up + // restart action for live Codex tabs. Prevent the menu from + // auto-closing so that prompt can stay within the same + // account-switcher interaction instead of jumping elsewhere. + event.preventDefault() + if (!target.active) { + void handleSelectAccount(target.id, target.runtimeTarget) + } + }} + disabled={isSwitching || target.active} + > +
+
+ {target.label} + {target.active ? ( + + Active + + ) : null} +
+ {inactiveUsage?.isFetching && !inactiveUsage.claude ? ( + + ) : inactiveUsage?.claude ? ( + + ) : null} +
+
+ ) + })} + + ) : null}
) : null} @@ -665,6 +1226,7 @@ function ProviderDetailsMenu({ compact, iconOnly, ariaLabel, + topContent, open, onOpenChange, children @@ -673,6 +1235,7 @@ function ProviderDetailsMenu({ compact: boolean iconOnly: boolean ariaLabel: string + topContent?: React.ReactNode open?: boolean onOpenChange?: (open: boolean) => void children?: React.ReactNode @@ -715,6 +1278,7 @@ function ProviderDetailsMenu({ + {topContent}
diff --git a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts new file mode 100644 index 00000000000..665273236b7 --- /dev/null +++ b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import type { + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState +} from '../../../../shared/types' +import { buildClaudeStatusSwitchGroups, buildCodexStatusSwitchGroups } from './StatusBar' + +const hostLabel = navigator.userAgent.includes('Windows') ? 'Windows' : 'This device' + +describe('status bar runtime switch groups', () => { + it('collapses WSL default into the single concrete Codex distro', () => { + const state: CodexRateLimitAccountsState = { + accounts: [ + { + id: 'codex-wsl', + email: 'wsl@example.com', + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'codex-wsl' } } + } + + expect( + buildCodexStatusSwitchGroups(state, { runtime: 'wsl', wslDistro: null }).map((group) => ({ + key: group.key, + label: group.label + })) + ).toEqual([ + { key: 'host', label: hostLabel }, + { key: 'wsl:Ubuntu', label: 'WSL Ubuntu' } + ]) + }) + + it('keeps the Claude WSL toggle available when Windows is selected', () => { + const state: ClaudeRateLimitAccountsState = { + accounts: [ + { + id: 'claude-host', + email: 'host@example.com', + managedAuthRuntime: 'host', + wslDistro: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'claude-wsl', + email: 'wsl@example.com', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeAccountId: 'claude-host', + activeAccountIdsByRuntime: { host: 'claude-host', wsl: { Ubuntu: 'claude-wsl' } } + } + + expect( + buildClaudeStatusSwitchGroups(state, { runtime: 'host', wslDistro: null }).map((group) => ({ + key: group.key, + label: group.label + })) + ).toEqual([ + { key: 'host', label: hostLabel }, + { key: 'wsl:Ubuntu', label: 'WSL Ubuntu' } + ]) + }) + + it('keeps Claude WSL system-default available without managed Claude accounts', () => { + const state: ClaudeRateLimitAccountsState = { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + + expect( + buildClaudeStatusSwitchGroups( + state, + { runtime: 'host', wslDistro: null }, + { includeFallbackWsl: true, fallbackWslDistro: 'Ubuntu' } + ).map((group) => ({ + key: group.key, + label: group.label, + targets: group.targets.map((target) => target.label) + })) + ).toEqual([ + { key: 'host', label: hostLabel, targets: ['System default'] }, + { key: 'wsl:Ubuntu', label: 'WSL Ubuntu', targets: ['System default'] } + ]) + }) +}) 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 2e242d50c44..401a62fa971 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 @@ -234,7 +234,10 @@ describe('TabBar PowerShell launch wiring', () => { it('passes pwsh.exe when the PowerShell menu item uses the PowerShell 7+ implementation', async () => { vi.stubGlobal('window', { api: { - wsl: { isAvailable: vi.fn().mockResolvedValue(false) }, + wsl: { + isAvailable: vi.fn().mockResolvedValue(false), + listDistros: vi.fn().mockResolvedValue([]) + }, pwsh: { isAvailable: vi.fn().mockResolvedValue(true) } } }) @@ -281,7 +284,10 @@ describe('TabBar PowerShell launch wiring', () => { it('shows the WSL terminal row when shared Windows capabilities report WSL', async () => { vi.stubGlobal('window', { api: { - wsl: { isAvailable: vi.fn().mockResolvedValue(true) }, + wsl: { + isAvailable: vi.fn().mockResolvedValue(true), + listDistros: vi.fn().mockResolvedValue(['Ubuntu']) + }, pwsh: { isAvailable: vi.fn().mockResolvedValue(false) } } }) diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index 1c036f8d530..67ebdbcab55 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { AppState } from '@/store/types' import { + getLocalAgentPreflightContext, getLocalPreflightContext, getWslDistroFromPath, localPreflightContextKey @@ -86,4 +87,51 @@ describe('local preflight context', () => { expect(getLocalPreflightContext(state)).toBeUndefined() expect(localPreflightContextKey(getLocalPreflightContext(state))).toBe('host') }) + + it('uses the selected WSL distro for local agent checks when WSL is the default shell', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + settings: { + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + } + } as AppState + + const context = getLocalAgentPreflightContext(state) + + expect(context).toEqual({ wslDistro: 'Debian' }) + expect(localPreflightContextKey(context)).toBe('wsl:Debian') + }) + + it('lets explicit agent location choose Windows even when the terminal shell is WSL', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + settings: { + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian', + localAgentRuntime: 'host' + } + } as AppState + + const context = getLocalAgentPreflightContext(state) + + expect(context).toBeUndefined() + expect(localPreflightContextKey(context)).toBe('host') + }) + + it('lets explicit agent location choose a WSL distro independent of the terminal shell', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + settings: { + terminalWindowsShell: 'powershell.exe', + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu' + } + } as AppState + + const context = getLocalAgentPreflightContext(state) + + expect(context).toEqual({ wslDistro: 'Ubuntu' }) + expect(localPreflightContextKey(context)).toBe('wsl:Ubuntu') + }) }) diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index 7f17191e680..ed61a7619ec 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -1,9 +1,10 @@ import type { AppState } from '@/store/types' import { parseWslUncPath } from '../../../shared/wsl-paths' -export type LocalPreflightContext = { wslDistro?: string | null } | undefined +export type LocalPreflightContext = { wslDistro?: string | null; wslDefault?: boolean } | undefined const wslPreflightContextsByDistro = new Map>() +const wslDefaultPreflightContext = Object.freeze({ wslDefault: true }) export function getWslDistroFromPath(path?: string | null): string | null { return path ? (parseWslUncPath(path)?.distro ?? null) : null @@ -23,6 +24,40 @@ function getWslPreflightContext(wslDistro: string): NonNullable repo.id === state.activeRepoId)?.path - const wslDistro = getWslDistroFromPath(activePath) - return wslDistro ? getWslPreflightContext(wslDistro) : undefined + return getWslDistroFromPath(activePath) } export function localPreflightContextKey(context: LocalPreflightContext): string { - return context?.wslDistro ? `wsl:${context.wslDistro}` : 'host' + if (context?.wslDistro) { + return `wsl:${context.wslDistro}` + } + return context?.wslDefault ? 'wsl:default' : 'host' } diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 411f11f129e..b12fc9c48c8 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -6,21 +6,27 @@ import { resetWindowsTerminalCapabilitiesForTests } from './windows-terminal-capabilities' -function stubTerminalCapabilityApi(args: { wslAvailable: boolean; pwshAvailable: boolean }): { +function stubTerminalCapabilityApi(args: { + wslAvailable: boolean + pwshAvailable: boolean + wslDistros?: string[] +}): { wslIsAvailable: ReturnType + wslListDistros: ReturnType pwshIsAvailable: ReturnType } { const wslIsAvailable = vi.fn().mockResolvedValue(args.wslAvailable) + const wslListDistros = vi.fn().mockResolvedValue(args.wslDistros ?? []) const pwshIsAvailable = vi.fn().mockResolvedValue(args.pwshAvailable) vi.stubGlobal('window', { api: { - wsl: { isAvailable: wslIsAvailable }, + wsl: { isAvailable: wslIsAvailable, listDistros: wslListDistros }, pwsh: { isAvailable: pwshIsAvailable } } }) - return { wslIsAvailable, pwshIsAvailable } + return { wslIsAvailable, wslListDistros, pwshIsAvailable } } describe('windows terminal capabilities', () => { @@ -37,16 +43,22 @@ describe('windows terminal capabilities', () => { expect(getCachedWindowsTerminalCapabilities()).toEqual({ wslAvailable: false, - pwshAvailable: false + wslDistros: [], + pwshAvailable: false, + isLoading: false }) await expect(loadWindowsTerminalCapabilities()).resolves.toEqual({ wslAvailable: true, - pwshAvailable: true + wslDistros: [], + pwshAvailable: true, + isLoading: false }) expect(getCachedWindowsTerminalCapabilities()).toEqual({ wslAvailable: true, - pwshAvailable: true + wslDistros: [], + pwshAvailable: true, + isLoading: false }) await loadWindowsTerminalCapabilities() @@ -59,14 +71,16 @@ describe('windows terminal capabilities', () => { const pwshIsAvailable = vi.fn().mockRejectedValue(new Error('pwsh probe failed')) vi.stubGlobal('window', { api: { - wsl: { isAvailable: wslIsAvailable }, + wsl: { isAvailable: wslIsAvailable, listDistros: vi.fn().mockResolvedValue([]) }, pwsh: { isAvailable: pwshIsAvailable } } }) await expect(loadWindowsTerminalCapabilities()).resolves.toEqual({ wslAvailable: true, - pwshAvailable: false + wslDistros: [], + pwshAvailable: false, + isLoading: false }) }) @@ -75,7 +89,7 @@ describe('windows terminal capabilities', () => { const pwshIsAvailable = vi.fn().mockResolvedValue(false) vi.stubGlobal('window', { api: { - wsl: { isAvailable: wslIsAvailable }, + wsl: { isAvailable: wslIsAvailable, listDistros: vi.fn().mockResolvedValue([]) }, pwsh: { isAvailable: pwshIsAvailable } } }) @@ -98,7 +112,7 @@ describe('windows terminal capabilities', () => { const pwshIsAvailable = vi.fn().mockResolvedValue(false) vi.stubGlobal('window', { api: { - wsl: { isAvailable: wslIsAvailable }, + wsl: { isAvailable: wslIsAvailable, listDistros: vi.fn().mockResolvedValue([]) }, pwsh: { isAvailable: pwshIsAvailable } } }) diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index 57e9ba33db3..51819a22964 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -2,12 +2,16 @@ import { useEffect, useState } from 'react' export type WindowsTerminalCapabilities = { wslAvailable: boolean + wslDistros: string[] pwshAvailable: boolean + isLoading: boolean } const UNAVAILABLE_CAPABILITIES: WindowsTerminalCapabilities = { wslAvailable: false, - pwshAvailable: false + wslDistros: [], + pwshAvailable: false, + isLoading: false } const CAPABILITY_CACHE_TTL_MS = 30_000 @@ -52,10 +56,11 @@ export function loadWindowsTerminalCapabilities( const requestId = ++latestCapabilityRequestId pendingCapabilities = Promise.all([ window.api.wsl.isAvailable().catch(() => false), + window.api.wsl.listDistros().catch(() => []), window.api.pwsh.isAvailable().catch(() => false) ]) - .then(([wslAvailable, pwshAvailable]) => { - const capabilities = { wslAvailable, pwshAvailable } + .then(([wslAvailable, wslDistros, pwshAvailable]) => { + const capabilities = { wslAvailable, wslDistros, pwshAvailable, isLoading: false } if (requestId === latestCapabilityRequestId) { pendingCapabilities = null publish(capabilities, now) @@ -79,7 +84,10 @@ export function refreshWindowsTerminalCapabilities(): Promise { @@ -88,14 +96,15 @@ export function useWindowsTerminalCapabilities(enabled: boolean): WindowsTermina return } - setCapabilities(getCachedWindowsTerminalCapabilities()) + const cached = getCachedWindowsTerminalCapabilities() + setCapabilities(cachedCapabilities ? cached : { ...cached, isLoading: true }) subscribers.add(setCapabilities) - void loadWindowsTerminalCapabilities().then(setCapabilities) + void loadWindowsTerminalCapabilities({ force: forceRefreshOnMount }).then(setCapabilities) return () => { subscribers.delete(setCapabilities) } - }, [enabled]) + }, [enabled, forceRefreshOnMount]) return enabled ? capabilities : UNAVAILABLE_CAPABILITIES } diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index 1c89b0113a3..5a85dbb8d8e 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -112,6 +112,71 @@ describe('createDetectedAgentsSlice WSL context', () => { expect(refreshAgents).toHaveBeenCalledWith({ wslDistro: 'Debian' }) }) + it('detects local agents in the default WSL distro when the default Windows shell is WSL', async () => { + const store = createTestStore({ + settings: { + terminalWindowsShell: 'wsl.exe' + } as AppState['settings'], + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + activeRepoId: 'repo-1', + activeWorktreeId: null + }) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + + expect(detectAgents).toHaveBeenCalledWith({ wslDefault: true }) + }) + + it('detects local agents in the selected WSL distro when the default Windows shell is WSL', async () => { + const store = createTestStore({ + settings: { + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + } as AppState['settings'], + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + activeRepoId: 'repo-1', + activeWorktreeId: null + }) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + + expect(detectAgents).toHaveBeenCalledWith({ wslDistro: 'Debian' }) + }) + + it('detects Windows agents when explicit agent location is Windows', async () => { + const store = createTestStore({ + settings: { + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian', + localAgentRuntime: 'host' + } as AppState['settings'], + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + activeRepoId: 'repo-1', + activeWorktreeId: null + }) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + + expect(detectAgents).toHaveBeenCalledWith(undefined) + }) + + it('detects WSL agents when explicit agent location is WSL', async () => { + const store = createTestStore({ + settings: { + terminalWindowsShell: 'powershell.exe', + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Fedora' + } as AppState['settings'], + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + activeRepoId: 'repo-1', + activeWorktreeId: null + }) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + + expect(detectAgents).toHaveBeenCalledWith({ wslDistro: 'Fedora' }) + }) + it('does not keep previous context agents when detection fails after a context switch', async () => { detectAgents .mockReset() diff --git a/src/renderer/src/store/slices/detected-agents.ts b/src/renderer/src/store/slices/detected-agents.ts index 9fa75afa55e..aa641beb9e3 100644 --- a/src/renderer/src/store/slices/detected-agents.ts +++ b/src/renderer/src/store/slices/detected-agents.ts @@ -1,7 +1,10 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { PathSource, ShellHydrationFailureReason, TuiAgent } from '../../../../shared/types' -import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' +import { + getLocalAgentPreflightContext, + localPreflightContextKey +} from '@/lib/local-preflight-context' export type DetectedAgentsSlice = { detectedAgentIds: TuiAgent[] | null @@ -48,7 +51,7 @@ export const createDetectedAgentsSlice: StateCreator { - const context = getLocalPreflightContext(get()) + const context = getLocalAgentPreflightContext(get()) const contextKey = localPreflightContextKey(context) const existing = get().detectedAgentIds if (existing && detectedContextKey === contextKey) { @@ -85,7 +88,7 @@ export const createDetectedAgentsSlice: StateCreator { - const context = getLocalPreflightContext(get()) + const context = getLocalAgentPreflightContext(get()) const contextKey = localPreflightContextKey(context) if (refreshPromise?.key === contextKey) { return refreshPromise.promise diff --git a/src/renderer/src/store/slices/preflight.ts b/src/renderer/src/store/slices/preflight.ts index 6818a32deba..15bff1f5be3 100644 --- a/src/renderer/src/store/slices/preflight.ts +++ b/src/renderer/src/store/slices/preflight.ts @@ -28,7 +28,7 @@ function getErrorMessage(error: unknown): string { function buildPreflightArgs( force: boolean, context: LocalPreflightContext -): { force?: boolean; wslDistro?: string | null } | undefined { +): { force?: boolean; wslDistro?: string | null; wslDefault?: boolean } | undefined { if (!force && !context) { return undefined } diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index 07fc408b4ba..4e0e142e6e3 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -1,22 +1,26 @@ import type { StateCreator } from 'zustand' -import type { RateLimitState } from '../../../../shared/rate-limit-types' +import type { RateLimitRuntimeTarget, RateLimitState } from '../../../../shared/rate-limit-types' import type { AppState } from '../types' export type RateLimitSlice = { rateLimits: RateLimitState fetchRateLimits: () => Promise refreshRateLimits: () => Promise + refreshClaudeRateLimitsForTarget: (target: RateLimitRuntimeTarget) => Promise + refreshCodexRateLimitsForTarget: (target: RateLimitRuntimeTarget) => Promise fetchInactiveClaudeAccountUsage: () => Promise fetchInactiveCodexAccountUsage: () => Promise setRateLimitsFromPush: (state: RateLimitState) => void } -export const createRateLimitSlice: StateCreator = (set) => ({ +export const createRateLimitSlice: StateCreator = (set, get) => ({ rateLimits: { claude: null, codex: null, gemini: null, opencodeGo: null, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], inactiveCodexAccounts: [] }, @@ -39,6 +43,66 @@ export const createRateLimitSlice: StateCreator { + const current = get().rateLimits + const targetChanged = + current.claudeTarget.runtime !== target.runtime || + current.claudeTarget.wslDistro !== target.wslDistro + set({ + rateLimits: { + ...current, + claudeTarget: target, + claude: + current.claude && !targetChanged + ? { ...current.claude, status: 'fetching' } + : { + provider: 'claude', + session: null, + weekly: null, + updatedAt: 0, + error: null, + status: 'fetching' + } + } + }) + try { + const state = await window.api.rateLimits.refreshClaudeForTarget(target) + set({ rateLimits: state }) + } catch (error) { + console.error('Failed to refresh Claude usage for runtime:', error) + } + }, + + refreshCodexRateLimitsForTarget: async (target) => { + const current = get().rateLimits + const targetChanged = + current.codexTarget.runtime !== target.runtime || + current.codexTarget.wslDistro !== target.wslDistro + set({ + rateLimits: { + ...current, + codexTarget: target, + codex: + current.codex && !targetChanged + ? { ...current.codex, status: 'fetching' } + : { + provider: 'codex', + session: null, + weekly: null, + updatedAt: 0, + error: null, + status: 'fetching' + } + } + }) + try { + const state = await window.api.rateLimits.refreshCodexForTarget(target) + set({ rateLimits: state }) + } catch (error) { + console.error('Failed to refresh Codex usage for runtime:', error) + } + }, + fetchInactiveClaudeAccountUsage: async () => { try { await window.api.rateLimits.fetchInactiveClaudeAccounts() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index de32e6b0bfb..d68eee67daa 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -439,7 +439,7 @@ function createWebPreloadApi(): Partial { }, pty: createPtyApi(), ssh: createSshApi(), - wsl: { isAvailable: () => Promise.resolve(false) }, + wsl: { isAvailable: () => Promise.resolve(false), listDistros: () => Promise.resolve([]) }, pwsh: { isAvailable: () => Promise.resolve(false) }, agentStatus: { onSet: () => noopUnsubscribe, @@ -1789,12 +1789,16 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { codex: null, gemini: null, opencodeGo: null, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], inactiveCodexAccounts: [] } return { get: () => Promise.resolve(empty), refresh: () => Promise.resolve(empty), + refreshCodexForTarget: () => Promise.resolve(empty), + refreshClaudeForTarget: () => Promise.resolve(empty), setPollingInterval: () => Promise.resolve(), fetchInactiveClaudeAccounts: () => Promise.resolve(), fetchInactiveCodexAccounts: () => Promise.resolve(), @@ -1803,7 +1807,11 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { } function createAccountsApi(): never { - const empty = { accounts: [], activeAccountId: null } + const empty = { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } return { list: () => Promise.resolve(empty), add: () => Promise.resolve(empty), diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 868e03b0beb..7a28fb5a33a 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -197,6 +197,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings { // and Ctrl+right-click still opens the context menu when paste is enabled. terminalRightClickToPaste: true, terminalWindowsShell: 'powershell.exe', + terminalWindowsWslDistro: null, + localAccountRuntime: 'host', + localAccountWslDistro: null, // Why: Windows users expect "PowerShell" to mean modern PowerShell when it // is installed, with a safe fallback to the inbox Windows PowerShell. terminalWindowsPowerShellImplementation: 'auto', @@ -237,6 +240,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { promptCacheTtlMs: 300_000, codexManagedAccounts: [], activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} }, claudeManagedAccounts: [], activeClaudeManagedAccountId: null, terminalScopeHistoryByWorktree: true, diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index a796cc663ab..3763510073f 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -32,6 +32,11 @@ export type ProviderRateLimits = { status: ProviderRateLimitStatus } +export type RateLimitRuntimeTarget = { + runtime: 'host' | 'wsl' + wslDistro: string | null +} + export type InactiveAccountUsage = { accountId: string claude: ProviderRateLimits | null @@ -44,6 +49,8 @@ export type RateLimitState = { codex: ProviderRateLimits | null gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null + claudeTarget: RateLimitRuntimeTarget + codexTarget: RateLimitRuntimeTarget inactiveClaudeAccounts: InactiveAccountUsage[] inactiveCodexAccounts: InactiveAccountUsage[] } diff --git a/src/shared/types.ts b/src/shared/types.ts index f98dd7057ee..f5edbbbaf37 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1480,6 +1480,9 @@ export type CodexManagedAccount = { id: string email: string managedHomePath: string + managedHomeRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxHomePath?: string | null providerAccountId?: string | null workspaceLabel?: string | null workspaceAccountId?: string | null @@ -1491,6 +1494,8 @@ export type CodexManagedAccount = { export type CodexManagedAccountSummary = { id: string email: string + managedHomeRuntime?: 'host' | 'wsl' + wslDistro?: string | null providerAccountId?: string | null workspaceLabel?: string | null workspaceAccountId?: string | null @@ -1502,12 +1507,21 @@ export type CodexManagedAccountSummary = { export type CodexRateLimitAccountsState = { accounts: CodexManagedAccountSummary[] activeAccountId: string | null + activeAccountIdsByRuntime?: CodexManagedAccountRuntimeSelection +} + +export type CodexManagedAccountRuntimeSelection = { + host: string | null + wsl: Record } export type ClaudeManagedAccount = { id: string email: string managedAuthPath: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxAuthPath?: string | null authMethod: 'subscription-oauth' | 'unknown' organizationUuid?: string | null organizationName?: string | null @@ -1519,6 +1533,8 @@ export type ClaudeManagedAccount = { export type ClaudeManagedAccountSummary = { id: string email: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null authMethod: 'subscription-oauth' | 'unknown' organizationUuid?: string | null organizationName?: string | null @@ -1530,6 +1546,12 @@ export type ClaudeManagedAccountSummary = { export type ClaudeRateLimitAccountsState = { accounts: ClaudeManagedAccountSummary[] activeAccountId: string | null + activeAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection +} + +export type ClaudeManagedAccountRuntimeSelection = { + host: string | null + wsl: Record } /** All AI coding agents Orca knows how to launch. Used for the agent picker in the new-workspace @@ -1725,6 +1747,20 @@ export type GlobalSettings = { * user's preferred shell. Defaults to 'powershell.exe' which is the * modern choice for an IDE context. Only consulted on Windows. */ terminalWindowsShell: string + /** Why: when WSL is the Windows default shell, users with multiple distros + * need Orca to launch terminals and scan agents in the same chosen distro + * instead of whatever WSL currently marks as its global default. */ + terminalWindowsWslDistro?: string | null + /** Why: account/auth location is independent from the user's preferred + * terminal shell. A user may default new terminals to WSL while still + * inspecting or adding Windows-scoped provider accounts. */ + localAccountRuntime: 'host' | 'wsl' + localAccountWslDistro?: string | null + /** Why: installed-agent detection is also a local environment choice. Keep + * it independent so users can inspect Windows and WSL PATH state without + * changing the default terminal shell. */ + localAgentRuntime?: 'host' | 'wsl' + localAgentWslDistro?: string | null /** Why: "PowerShell" is the product-facing shell family. Auto resolves to * PowerShell 7+ when present and falls back to inbox Windows PowerShell. */ terminalWindowsPowerShellImplementation: 'auto' | 'powershell.exe' | 'pwsh.exe' @@ -1812,11 +1848,13 @@ export type GlobalSettings = { * and external terminal sessions. */ codexManagedAccounts: CodexManagedAccount[] activeCodexManagedAccountId: string | null + activeCodexManagedAccountIdsByRuntime?: CodexManagedAccountRuntimeSelection /** Why: Claude Code keeps conversations under one shared config root. Orca * persists only per-account auth material here so switching accounts does * not fork prior chat/session context the way CLAUDE_CONFIG_DIR swapping would. */ claudeManagedAccounts: ClaudeManagedAccount[] activeClaudeManagedAccountId: string | null + activeClaudeManagedAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection /** When true, each worktree gets its own shell history file so ArrowUp * does not surface commands from other worktrees. Defaults to true. * Disable to revert to shared global shell history. */ From 399ad3e42de240b262022e665b423803a12c6ad8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 16:13:49 -0700 Subject: [PATCH 017/224] Document workspace board interaction audit (#3366) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index d54e17f2d94..88cf7604e7d 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,7 +49,7 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026: 911 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, #3349, #3354, #3356, and #3361 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, #3253, and #3351 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, #3349, #3354, #3356, #3361, and #3365 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, #3253, and #3351 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -58,7 +58,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Renderer mobile page | `components/mobile/**` | Inventory complete, manual review in progress | Mobile pairing page stage repair covered by #3180. Continue checking QR generation, mobile-page IPC, and paired-device polling. | | Browser pane | `components/browser-pane/**` | Inventory complete, manual review in progress | Highest Effect density: 62 sites in `BrowserPane.tsx`. Browser ref mirrors covered by #3081; BrowserFind debounced-query state covered by #3275. Continue with driver sync, address bar derived state, remaining find state, webview lifetime. | | Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328; diff-comment popover focus covered by #3333; markdown annotation composer focus covered by #3338; diff-comment card resize covered by #3354; contextual-copy toast cleanup covered by #3356. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | -| Sidebar / worktrees | `components/sidebar/**` | Inventory complete, manual review in progress | Orca hook trust checkbox reset covered by #3089; delete dialog one-shot checkbox reset covered by #3134; Add Repo clone destination auto-fill covered by #3150; sidebar live-width mirror covered by #3160; worktree selection pruning covered by #3166; SSH reconnect prompts covered by #3267; nested repo indeterminate checkbox covered by #3294; workspace title rename focus covered by #3296; workspace title editing notification covered by #3298; worktree metadata notes resize covered by #3300. Continue with drag/drop global listeners and kanban pointer flows. | +| Sidebar / worktrees | `components/sidebar/**` | Inventory complete, manual review in progress | Orca hook trust checkbox reset covered by #3089; delete dialog one-shot checkbox reset covered by #3134; Add Repo clone destination auto-fill covered by #3150; sidebar live-width mirror covered by #3160; worktree selection pruning covered by #3166; SSH reconnect prompts covered by #3267; nested repo indeterminate checkbox covered by #3294; workspace title rename focus covered by #3296; workspace title editing notification covered by #3298; worktree metadata notes resize covered by #3300; workspace board open interaction covered by #3365. Continue with drag/drop global listeners and kanban pointer flows. | | Workspace cleanup | `components/workspace-cleanup/**` | Inventory complete, manual review in progress | Active cleanup-view repair covered by #3195. Continue checking destructive removal, repo filtering, scan refresh, and SSH/disconnected blockers. | | Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212; file-search input focus covered by #3330; file explorer inline create/rename focus covered by #3347. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | | Settings | `components/settings/**` | Inventory complete, manual review in progress | Repository hook local editor visibility covered by #3093; lazy section mounted-state bookkeeping covered by #3128; browser home/Kagi draft sync covered by #3136; general auto-save/Open In draft sync covered by #3138; Source Control AI instruction drafts covered by #3140; notification volume draft sync covered by #3142; repo Source Control AI override draft sync covered by #3144; Quick Commands add intent covered by #3146; Mobile QR polling count capture covered by #3152; integration preflight status derivation covered by #3154; SSH reset-relay dialog cleanup covered by #3157; diagnostics bundle cleanup ref mirror covered by #3176; font autocomplete option scroll covered by #3349; SSH passphrase prompt focus covered by #3351. Continue with draft mirrors while keeping SSH and cross-platform settings behavior intact. | @@ -220,6 +220,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR EK | Diff comment card resize | Diff comment cards remeasured Monaco view-zone content from a passive editing-state Effect after save or cancel closed edit mode. | `DiffCommentCard.tsx` covered by #3354 | Medium | | PR EL | Contextual-copy toast cleanup | The editor contextual-copy hook used a passive mount Effect only to clear its confirmation-toast timeout on unmount. | `useContextualCopySetup.tsx`, `setup-contextual-copy.ts` covered by #3356 | Medium | | PR EM | Space page open interaction | The Space page recorded workspace-cleanup feature usage from a passive mount Effect after the page rendered. | `WorkspaceSpacePage.tsx`, `ui.ts` covered by #3361 | Medium | +| PR EN | Workspace board open interaction | The workspace board drawer recorded feature usage from a passive open-state Effect after the drawer rendered. | `SidebarHeader.tsx`, `WorkspaceKanbanDrawer.tsx` covered by #3365 | Medium | ## Merge Risk Scale @@ -368,6 +369,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3354 | `nwparker/react-perf-diff-comment-card-resize` | Diff comment card close-after-edit resize moves to save/cancel handlers | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/diff-comments/DiffCommentCard.tsx`; `pnpm exec oxlint src/renderer/src/components/diff-comments/DiffCommentCard.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3356 | `nwparker/react-perf-contextual-copy-timer-dispose` | Contextual-copy toast timeout cleanup moves to Monaco editor disposal | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/useContextualCopySetup.tsx src/renderer/src/components/editor/setup-contextual-copy.ts`; `pnpm exec oxlint src/renderer/src/components/editor/useContextualCopySetup.tsx src/renderer/src/components/editor/setup-contextual-copy.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/selection-copy.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3361 | `nwparker/react-perf-space-page-interaction` | Space page workspace-cleanup interaction records in `openSpacePage` | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx src/renderer/src/store/slices/ui.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm exec oxlint src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx src/renderer/src/store/slices/ui.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | +| #3365 | `nwparker/react-perf-workspace-board-open-interaction` | Workspace board feature interaction records in open handlers | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/sidebar/SidebarHeader.tsx src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx`; `pnpm exec oxlint src/renderer/src/components/sidebar/SidebarHeader.tsx src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | ## Reproduction Commands From 6a3574af53a142a2fe43c655ef87d69a3aaff9ef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 29 May 2026 16:17:22 -0700 Subject: [PATCH 018/224] Track app-origin GitHub stars (#3310) Co-authored-by: Orca --- src/main/ipc/github.test.ts | 94 ++++++++++++++++++- src/main/ipc/github.ts | 17 +++- src/main/ipc/telemetry.test.ts | 8 ++ src/main/ipc/telemetry.ts | 10 +- src/preload/api-types.ts | 3 +- src/preload/index.ts | 4 +- src/renderer/src/components/Landing.tsx | 2 +- src/renderer/src/components/StarNagCard.tsx | 2 +- .../src/components/settings/GeneralPane.tsx | 2 +- src/shared/gh-star-source.ts | 8 ++ src/shared/telemetry-events.test.ts | 33 +++++++ src/shared/telemetry-events.ts | 10 ++ 12 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 src/shared/gh-star-source.ts diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 23c6afaa67c..c527c805ee4 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -8,6 +8,10 @@ const { listWorkItemsMock, getAuthenticatedViewerMock, mergePRMock, + checkOrcaStarredMock, + starOrcaMock, + trackMock, + getCohortAtEmitMock, getAllWebContentsMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -17,6 +21,10 @@ const { listWorkItemsMock: vi.fn(), getAuthenticatedViewerMock: vi.fn(), mergePRMock: vi.fn(), + checkOrcaStarredMock: vi.fn(), + starOrcaMock: vi.fn(), + trackMock: vi.fn(), + getCohortAtEmitMock: vi.fn(), getAllWebContentsMock: vi.fn() })) @@ -35,7 +43,17 @@ vi.mock('../github/client', () => ({ listIssues: listIssuesMock, listWorkItems: listWorkItemsMock, getAuthenticatedViewer: getAuthenticatedViewerMock, - mergePR: mergePRMock + mergePR: mergePRMock, + checkOrcaStarred: checkOrcaStarredMock, + starOrca: starOrcaMock +})) + +vi.mock('../telemetry/client', () => ({ + track: trackMock +})) + +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: getCohortAtEmitMock })) import { registerGitHubHandlers } from './github' @@ -70,6 +88,11 @@ describe('registerGitHubHandlers', () => { listWorkItemsMock.mockReset() getAuthenticatedViewerMock.mockReset() mergePRMock.mockReset() + checkOrcaStarredMock.mockReset() + starOrcaMock.mockReset() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: undefined }) getAllWebContentsMock.mockReset() getAllWebContentsMock.mockReturnValue([]) for (const key of Object.keys(handlers)) { @@ -270,4 +293,73 @@ describe('registerGitHubHandlers', () => { }) expect(getAuthenticatedViewerMock).toHaveBeenCalled() }) + + it('emits app_starred_orca once after a successful star with cohort context', async () => { + starOrcaMock.mockResolvedValue(true) + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 3 }) + + registerGitHubHandlers(store as never, stats as never) + + await expect(handlers['gh:starOrca'](null, 'settings')).resolves.toBe(true) + + expect(starOrcaMock).toHaveBeenCalledTimes(1) + expect(getCohortAtEmitMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledWith('app_starred_orca', { + source: 'settings', + nth_repo_added: 3 + }) + }) + + it('accepts every app star source for success telemetry', async () => { + starOrcaMock.mockResolvedValue(true) + + registerGitHubHandlers(store as never, stats as never) + + for (const source of ['star_nag', 'settings', 'landing'] as const) { + await expect(handlers['gh:starOrca'](null, source)).resolves.toBe(true) + } + + expect(trackMock).toHaveBeenCalledTimes(3) + expect(trackMock.mock.calls.map(([, props]) => props)).toEqual([ + { source: 'star_nag', nth_repo_added: undefined }, + { source: 'settings', nth_repo_added: undefined }, + { source: 'landing', nth_repo_added: undefined } + ]) + }) + + it('does not emit app_starred_orca when the star action returns false', async () => { + starOrcaMock.mockResolvedValue(false) + + registerGitHubHandlers(store as never, stats as never) + + await expect(handlers['gh:starOrca'](null, 'landing')).resolves.toBe(false) + + expect(starOrcaMock).toHaveBeenCalledTimes(1) + expect(trackMock).not.toHaveBeenCalled() + expect(getCohortAtEmitMock).not.toHaveBeenCalled() + }) + + it('does not emit app_starred_orca when the star action throws', async () => { + starOrcaMock.mockRejectedValue(new Error('gh failed')) + + registerGitHubHandlers(store as never, stats as never) + + await expect(handlers['gh:starOrca'](null, 'star_nag')).rejects.toThrow('gh failed') + + expect(trackMock).not.toHaveBeenCalled() + expect(getCohortAtEmitMock).not.toHaveBeenCalled() + }) + + it('preserves star result but skips telemetry for an invalid IPC source', async () => { + starOrcaMock.mockResolvedValue(true) + + registerGitHubHandlers(store as never, stats as never) + + await expect(handlers['gh:starOrca'](null, 'github_website')).resolves.toBe(true) + + expect(starOrcaMock).toHaveBeenCalledTimes(1) + expect(trackMock).not.toHaveBeenCalled() + expect(getCohortAtEmitMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 332cecb7dee..4eb3ab744bd 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -92,6 +92,9 @@ import type { UpdateProjectItemFieldArgs, UpdatePullRequestBySlugArgs } from '../../shared/github-project-types' +import { appStarSourceSchema } from '../../shared/gh-star-source' +import { track } from '../telemetry/client' +import { getCohortAtEmit } from '../telemetry/cohort-classifier' // Why: notify every renderer (each window has its own SWR cache instance) // that a work item was mutated locally so they can drop their cached entry @@ -844,7 +847,19 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi // Star operations target the Orca repo itself — no repoPath validation needed ipcMain.handle('gh:viewer', () => getAuthenticatedViewer()) ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) - ipcMain.handle('gh:starOrca', () => starOrca()) + ipcMain.handle('gh:starOrca', async (_event, source: unknown) => { + const sourceParse = appStarSourceSchema.safeParse(source) + const starred = await starOrca() + if (starred && sourceParse.success) { + // Why: this main-owned event bypasses renderer telemetry IPC, so cohort + // context must be attached here on the successful star path. + track('app_starred_orca', { + source: sourceParse.data, + ...getCohortAtEmit() + }) + } + return starred + }) // Why: `rate_limit` is exempt from GitHub's rate-limit accounting, so // polling is cheap. A 30s in-process cache still avoids the gh subprocess diff --git a/src/main/ipc/telemetry.test.ts b/src/main/ipc/telemetry.test.ts index 3499ac466c2..4d02a8d1df6 100644 --- a/src/main/ipc/telemetry.test.ts +++ b/src/main/ipc/telemetry.test.ts @@ -156,6 +156,14 @@ describe('telemetry IPC handlers', () => { }) }) + it('drops main-owned events from renderer telemetry IPC', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + const handler = handlers.get('telemetry:track')! + handler({}, 'app_starred_orca', { source: 'settings' }) + expect(trackMock).not.toHaveBeenCalled() + expect(getCohortAtEmitMock).not.toHaveBeenCalled() + }) + it('injects cohort for setup script prompt events', () => { registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 3 }) diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index 29d57536d6d..c5fe79b833d 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -53,6 +53,8 @@ import type { OptInVia } from '../../shared/telemetry-events' // mirrors how other core-handlers accept the store explicitly. let storeRef: Store | null = null +const MAIN_OWNED_TELEMETRY_EVENTS = new Set(['app_starred_orca']) + /** * Derive the `via` discriminator for a `telemetry:setOptIn` call from * main-owned state. Called BEFORE any state mutation so the cohort + opt-in @@ -117,6 +119,13 @@ export function registerTelemetryHandlers(store: Store): void { if (props !== null && props !== undefined && typeof props !== 'object') { return } + const eventName = name as EventName + // Why: some event schemas are registered for main-owned emissions only. + // Letting renderer IPC emit them would let compromised content spoof + // product outcomes that must be tied to a successful main-side action. + if (MAIN_OWNED_TELEMETRY_EVENTS.has(eventName)) { + return + } // Inject cohort here, at the IPC entry, only for events whose schemas // declare `nth_repo_added` (see `COHORT_EXTENDED` in telemetry-events.ts). // The selectivity is load-bearing: schemas are `.strict()`, so adding @@ -130,7 +139,6 @@ export function registerTelemetryHandlers(store: Store): void { // The two injection sets are disjoint by construction today — no schema // declares both `nth_repo_added` and `cohort` — but combining them via // spread keeps that an additive change rather than a structural one. - const eventName = name as EventName const baseProps = (props ?? {}) as Record const withRepoCohort = isCohortExtendedEvent(eventName) ? { ...baseProps, ...getCohortAtEmit() } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 4a3c56dac8f..c9fc9ca9b39 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -272,6 +272,7 @@ import type { } from '../shared/opencode-usage-types' import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' +import type { AppStarSource } from '../shared/gh-star-source' import type { RemoteWorkspaceChangedEvent, RemoteWorkspaceConnectedClient, @@ -1072,7 +1073,7 @@ export type PreloadApi = { }) => void ) => () => void checkOrcaStarred: () => Promise - starOrca: () => Promise + starOrca: (source: AppStarSource) => Promise /** * GitHub API rate-limit snapshot. Does NOT consume quota (the * `rate_limit` endpoint is exempt). Cached 30s server-side — pass diff --git a/src/preload/index.ts b/src/preload/index.ts index 9a0e8a51589..1b52641f5f9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -120,6 +120,7 @@ import type { import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { RefreshAgentsResult } from './api-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' +import type { AppStarSource } from '../shared/gh-star-source' import type { Automation, AutomationCreateInput, @@ -1164,7 +1165,8 @@ const api = { }, checkOrcaStarred: (): Promise => ipcRenderer.invoke('gh:checkOrcaStarred'), - starOrca: (): Promise => ipcRenderer.invoke('gh:starOrca'), + starOrca: (source: AppStarSource): Promise => + ipcRenderer.invoke('gh:starOrca', source), // Why: rate_limit is exempt from rate-limit accounting, but we still pass // `force` through so callers can bust the 30s in-process cache after a diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 3d0ac6513f5..a2b9652babf 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -104,7 +104,7 @@ function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Elemen return } setState('starred') // optimistic - const ok = await window.api.gh.starOrca() + const ok = await window.api.gh.starOrca('landing') if (!ok) { setState('not-starred') return diff --git a/src/renderer/src/components/StarNagCard.tsx b/src/renderer/src/components/StarNagCard.tsx index cc8ee1f8c9b..43f78576bb5 100644 --- a/src/renderer/src/components/StarNagCard.tsx +++ b/src/renderer/src/components/StarNagCard.tsx @@ -66,7 +66,7 @@ export function StarNagCard(): React.JSX.Element | null { } setBusy(true) setError(false) - const ok = await window.api.gh.starOrca() + const ok = await window.api.gh.starOrca('star_nag') setBusy(false) if (!ok) { setError(true) diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index d16a2b3f9dc..2937d23917e 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -153,7 +153,7 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea return } setStarState('starring') - const ok = await window.api.gh.starOrca() + const ok = await window.api.gh.starOrca('settings') if (!ok) { setStarState('error') return diff --git a/src/shared/gh-star-source.ts b/src/shared/gh-star-source.ts new file mode 100644 index 00000000000..4e11266af8c --- /dev/null +++ b/src/shared/gh-star-source.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const APP_STAR_SOURCE_VALUES = ['star_nag', 'settings', 'landing'] as const + +// Why: renderer-originated IPC is untrusted, so main validates against this +// closed enum before attaching source context to successful star telemetry. +export const appStarSourceSchema = z.enum(APP_STAR_SOURCE_VALUES) +export type AppStarSource = z.infer diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index 37972dd915f..29e91721643 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -14,6 +14,39 @@ import { SETTINGS_CHANGED_WHITELIST, settingsChangedKeySchema } from './telemetry-events' +import { appStarSourceSchema } from './gh-star-source' + +describe('app_starred_orca schema', () => { + it('accepts every declared app star source', () => { + for (const source of appStarSourceSchema.options) { + const parsed = eventSchemas.app_starred_orca.safeParse({ source }) + expect(parsed.success).toBe(true) + } + }) + + it('accepts cohort context on successful app star telemetry', () => { + const parsed = eventSchemas.app_starred_orca.safeParse({ + source: 'settings', + nth_repo_added: 2 + }) + expect(parsed.success).toBe(true) + }) + + it('rejects unknown app star source values', () => { + const parsed = eventSchemas.app_starred_orca.safeParse({ + source: 'github_website' + }) + expect(parsed.success).toBe(false) + }) + + it('rejects extra keys via .strict()', () => { + const parsed = eventSchemas.app_starred_orca.safeParse({ + source: 'landing', + repo: 'stablyai/orca' + }) + expect(parsed.success).toBe(false) + }) +}) describe('agent_error schema', () => { it('round-trips a minimal {error_class, agent_kind} payload', () => { diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index c3439c4d520..fb62716ceeb 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -18,6 +18,7 @@ import { FEATURE_WALL_MAX_DWELL_MS } from './feature-wall-telemetry' import { FEATURE_WALL_EXIT_ACTIONS, FEATURE_WALL_TOUR_DEPTH_STEPS } from './feature-wall-tour-depth' import { SETUP_SCRIPT_IMPORT_PROVIDERS } from './setup-script-import-providers' import { WORKSPACE_SOURCE_VALUES, type WorkspaceSource } from './workspace-source' +import { appStarSourceSchema } from './gh-star-source' import { NESTED_REPO_COUNT_BUCKETS, NESTED_REPO_IMPORT_ACTIONS, @@ -267,6 +268,13 @@ const repoAddedSchema = z .object({ method: repoMethodSchema, nth_repo_added: nthRepoAddedSchema }) .strict() +const appStarredOrcaSchema = z + .object({ + source: appStarSourceSchema, + nth_repo_added: nthRepoAddedSchema + }) + .strict() + const workspaceCreatedSchema = z .object({ source: workspaceSourceSchema, @@ -1014,6 +1022,7 @@ const onboardingFeatureSetupTerminalInteractedSchema = z // which cannot be unmixed after the fact. export const eventSchemas = { app_opened: appOpenedSchema, + app_starred_orca: appStarredOrcaSchema, repo_added: repoAddedSchema, add_repo_setup_step_action: addRepoSetupStepActionEventSchema, @@ -1107,6 +1116,7 @@ export const COHORT_EXTENDED: readonly EventName[] = Array.from(COHORT_EXTENDED_ // injection set against silent schema drift. type _CohortExtendedRoster = | 'app_opened' + | 'app_starred_orca' | 'repo_added' | 'add_repo_setup_step_action' | 'add_repo_existing_workspaces_detected' From 56928007f2f99f6b4b17257b0dfddcdf53ae8316 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 29 May 2026 19:17:34 -0400 Subject: [PATCH 019/224] Avoid double-closing Windows ConPTY handles (#3359) --- src/main/providers/local-pty-provider.test.ts | 37 +++++++++++++++++++ src/main/providers/local-pty-provider.ts | 14 ++++--- .../rate-limits/hidden-pty-cleanup.test.ts | 19 +++++++++- src/main/rate-limits/hidden-pty-cleanup.ts | 6 +++ 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 09b2dc3e225..9871805b496 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -472,6 +472,25 @@ describe('LocalPtyProvider', () => { expect(onExit).toHaveBeenCalledWith(id, -1) }) + it('does not destroy after an intentional Windows shutdown kill', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const killSpy = vi.fn() + const destroySpy = vi.fn(() => { + killSpy() + }) + spawnMock.mockReturnValue({ + ...mockProc, + kill: killSpy, + destroy: destroySpy + }) + + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + await provider.shutdown(id, { immediate: true }) + + expect(killSpy).toHaveBeenCalledTimes(1) + expect(destroySpy).not.toHaveBeenCalled() + }) + it('is a no-op for unknown PTY ids', async () => { await provider.shutdown('nonexistent', { immediate: true }) expect(mockProc.kill).not.toHaveBeenCalled() @@ -611,5 +630,23 @@ describe('LocalPtyProvider', () => { const list = await provider.listProcesses() expect(list).toHaveLength(0) }) + + it('does not destroy after intentional Windows orphan kills', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const destroySpy = vi.fn() + const killSpy = vi.fn() + spawnMock.mockReturnValue({ + ...mockProc, + kill: killSpy, + destroy: destroySpy + }) + + await provider.spawn({ cols: 80, rows: 24 }) + + provider.killAll() + + expect(killSpy).toHaveBeenCalledTimes(1) + expect(destroySpy).not.toHaveBeenCalled() + }) }) }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 7e6772b97e5..c14c9b88884 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -114,7 +114,7 @@ function clearPtyState(id: string): void { ptyLoadGeneration.delete(id) } -function destroyPtyProcess(proc: pty.IPty): void { +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 // (see docs/fix-pty-fd-leak.md). destroy() also registers a close listener @@ -122,9 +122,11 @@ function destroyPtyProcess(proc: pty.IPty): void { // the time that listener runs the child may have exited and its pid been // recycled to an unrelated user process — SIGHUP would land on a Chrome tab, // editor, etc. Neutralize proc.kill on this instance before calling - // destroy() to defuse the hazard. Windows exempt: WindowsTerminal.destroy - // IS a kill() call via _deferNoArgs, so neutralizing it would leak the - // ConPTY agent. + // destroy() to defuse the hazard. On Windows, destroy() is itself kill(); + // skip it only after we have already killed the ConPTY. + if (process.platform === 'win32' && options.alreadyKilled) { + return + } if (process.platform !== 'win32') { ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} } @@ -142,7 +144,7 @@ function safeKillAndClean(id: string, proc: pty.IPty): void { } catch { /* Process may already be dead */ } - destroyPtyProcess(proc) + destroyPtyProcess(proc, { alreadyKilled: true }) clearPtyState(id) } @@ -557,7 +559,7 @@ export class LocalPtyProvider implements IPtyProvider { } catch { /* Process may already be dead */ } - destroyPtyProcess(proc) + destroyPtyProcess(proc, { alreadyKilled: true }) ptyProcesses.delete(id) ptyShellName.delete(id) ptyLoadGeneration.delete(id) diff --git a/src/main/rate-limits/hidden-pty-cleanup.test.ts b/src/main/rate-limits/hidden-pty-cleanup.test.ts index cc1143279d1..094931a97b0 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.test.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.test.ts @@ -69,7 +69,7 @@ describe('cleanupHiddenRateLimitPty', () => { expect(killMock).toHaveBeenCalledWith() }) - it('does not neutralize kill on Windows because destroy closes ConPTY through kill', () => { + it('does not destroy after an intentional Windows kill because destroy kills again', () => { setPlatform('win32') const term = { kill: vi.fn(), @@ -80,7 +80,22 @@ describe('cleanupHiddenRateLimitPty', () => { cleanupHiddenRateLimitPty(term, [], { kill: true }) - expect(term.kill).toHaveBeenCalledTimes(2) + expect(term.kill).toHaveBeenCalledTimes(1) + expect(term.destroy).not.toHaveBeenCalled() + }) + + it('destroys a Windows PTY after natural exit so ConPTY cleanup still runs', () => { + setPlatform('win32') + const term = { + kill: vi.fn(), + destroy: vi.fn(() => { + term.kill() + }) + } + + cleanupHiddenRateLimitPty(term, [], { kill: false }) + + expect(term.kill).toHaveBeenCalledTimes(1) expect(term.destroy).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts index 215bccda7bc..6d85421203c 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.ts @@ -22,6 +22,12 @@ export function cleanupHiddenRateLimitPty( } catch { /* already exited */ } + + // Why: node-pty WindowsTerminal.destroy() calls kill() again, which can + // close the same ConPTY handle twice after an intentional termination. + if (process.platform === 'win32') { + return + } } // Why: node-pty destroy releases the master PTY fd; on POSIX, neutralize From aeadf5a035840b61cf5dafe77472cad95624f1f2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 16:20:02 -0700 Subject: [PATCH 020/224] Document activity portal subscription audit (#3368) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index 88cf7604e7d..12bfe36f779 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,12 +49,12 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026: 911 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, #3349, #3354, #3356, #3361, and #3365 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, #3253, and #3351 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, #3349, #3354, #3356, #3361, and #3365 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, #3253, #3351, and #3367 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | Renderer app shell | `src/renderer/src/App.tsx`, root components | Inventory complete, manual review in progress | Confirmation dialog active request mirror covered by #3091; onboarding settings detour reset covered by #3122; quick workspace agent selection repair covered by #3148; Cmd+J selection repair covered by #3164; smart workspace command highlight covered by #3192; App lazy-modal mount bookkeeping covered by #3220; shared reduced-motion media-query hook covered by #3287; tab-entry selected action reset covered by #3311. Continue checking global listeners, beforeunload, media-query, and active-tab repair. | -| Terminal / PTY | `components/Terminal.tsx`, `components/terminal-pane/**`, `components/terminal/**` | Inventory complete, manual review pending | Mobile driver overlay collapse repair covered by #3170; floating workspace first-open bounds normalization covered by #3269; unused legacy terminal shell/shortcut/hook files deleted by #3271; terminal tab rename focus covered by #3314 without PTY protocol changes; terminal search focus covered by #3344. High-risk area: xterm lifecycle, scrollback, remote/mobile parity, focus, WebGL, resize. | +| Terminal / PTY | `components/Terminal.tsx`, `components/terminal-pane/**`, `components/terminal/**` | Inventory complete, manual review pending | Mobile driver overlay collapse repair covered by #3170; floating workspace first-open bounds normalization covered by #3269; unused legacy terminal shell/shortcut/hook files deleted by #3271; terminal tab rename focus covered by #3314 without PTY protocol changes; terminal search focus covered by #3344; Activity terminal portal subscription covered by #3367. High-risk area: xterm lifecycle, scrollback, remote/mobile parity, focus, WebGL, resize. | | Renderer mobile page | `components/mobile/**` | Inventory complete, manual review in progress | Mobile pairing page stage repair covered by #3180. Continue checking QR generation, mobile-page IPC, and paired-device polling. | | Browser pane | `components/browser-pane/**` | Inventory complete, manual review in progress | Highest Effect density: 62 sites in `BrowserPane.tsx`. Browser ref mirrors covered by #3081; BrowserFind debounced-query state covered by #3275. Continue with driver sync, address bar derived state, remaining find state, webview lifetime. | | Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328; diff-comment popover focus covered by #3333; markdown annotation composer focus covered by #3338; diff-comment card resize covered by #3354; contextual-copy toast cleanup covered by #3356. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | @@ -221,6 +221,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR EL | Contextual-copy toast cleanup | The editor contextual-copy hook used a passive mount Effect only to clear its confirmation-toast timeout on unmount. | `useContextualCopySetup.tsx`, `setup-contextual-copy.ts` covered by #3356 | Medium | | PR EM | Space page open interaction | The Space page recorded workspace-cleanup feature usage from a passive mount Effect after the page rendered. | `WorkspaceSpacePage.tsx`, `ui.ts` covered by #3361 | Medium | | PR EN | Workspace board open interaction | The workspace board drawer recorded feature usage from a passive open-state Effect after the drawer rendered. | `SidebarHeader.tsx`, `WorkspaceKanbanDrawer.tsx` covered by #3365 | Medium | +| PR EO | Activity terminal portal subscription | Activity terminal portal targets were mirrored from module state through a layout Effect and local state. | `activity-terminal-portal.ts` covered by #3367 | High | ## Merge Risk Scale @@ -370,6 +371,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3356 | `nwparker/react-perf-contextual-copy-timer-dispose` | Contextual-copy toast timeout cleanup moves to Monaco editor disposal | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/useContextualCopySetup.tsx src/renderer/src/components/editor/setup-contextual-copy.ts`; `pnpm exec oxlint src/renderer/src/components/editor/useContextualCopySetup.tsx src/renderer/src/components/editor/setup-contextual-copy.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/selection-copy.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3361 | `nwparker/react-perf-space-page-interaction` | Space page workspace-cleanup interaction records in `openSpacePage` | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx src/renderer/src/store/slices/ui.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm exec oxlint src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx src/renderer/src/store/slices/ui.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3365 | `nwparker/react-perf-workspace-board-open-interaction` | Workspace board feature interaction records in open handlers | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/sidebar/SidebarHeader.tsx src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx`; `pnpm exec oxlint src/renderer/src/components/sidebar/SidebarHeader.tsx src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | +| #3367 | `nwparker/react-perf-activity-terminal-portals-store` | Activity terminal portal targets use `useSyncExternalStore` | High | Open | `pnpm exec oxfmt --write src/renderer/src/components/activity/activity-terminal-portal.ts`; `pnpm exec oxlint src/renderer/src/components/activity/activity-terminal-portal.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/activity/ActivityPrototypePage.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | ## Reproduction Commands From 39e6f85fdcd11b297b648359639ca8d191a85a35 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 29 May 2026 16:20:13 -0700 Subject: [PATCH 021/224] Add Orca CLI feature tip (#3279) Co-authored-by: Orca --- src/cli/runtime/transport.test.ts | 6 +- src/renderer/src/App.tsx | 37 ++- src/renderer/src/assets/main.css | 122 ++++++-- .../src/components/WorktreeJumpPalette.tsx | 4 + .../components/cmd-j/palette-results.test.ts | 33 +- .../src/components/cmd-j/palette-results.ts | 48 ++- .../feature-tips/FeatureTipsModal.tsx | 295 +++++++++++++++++- .../feature-tip-cli-install-action.test.ts | 56 ++++ .../feature-tip-cli-install-action.ts | 15 + .../feature-tip-modal-state.test.ts | 32 +- .../feature-tips/feature-tip-modal-state.ts | 2 + .../feature-tip-startup-gate.test.ts | 118 ++++++- .../feature-tips/feature-tip-startup-gate.ts | 10 + .../feature-tip-telemetry.test.ts | 49 +++ .../feature-tips/feature-tip-telemetry.ts | 24 ++ .../AgentsOrchestrationVisual.tsx | 16 +- .../OrchestrationPage.tsx | 244 ++++++++------- .../orchestration-cards.tsx | 55 +++- .../orchestration-types.ts | 2 + .../OnboardingInlineCommandTerminal.tsx | 4 +- .../src/components/settings/CliSection.tsx | 2 +- .../src/components/settings/general-search.ts | 6 +- .../components/settings/settings-search.ts | 2 + .../src/lib/agent-feature-install-commands.ts | 1 + src/shared/agent-feature-install-commands.ts | 5 + src/shared/feature-tips.test.ts | 50 ++- src/shared/feature-tips.ts | 22 +- src/shared/telemetry-events.test.ts | 40 +++ src/shared/telemetry-events.ts | 28 ++ .../e2e-completed-onboarding-profile.ts | 2 +- 30 files changed, 1127 insertions(+), 203 deletions(-) create mode 100644 src/renderer/src/components/feature-tips/feature-tip-cli-install-action.test.ts create mode 100644 src/renderer/src/components/feature-tips/feature-tip-cli-install-action.ts create mode 100644 src/renderer/src/components/feature-tips/feature-tip-telemetry.test.ts create mode 100644 src/renderer/src/components/feature-tips/feature-tip-telemetry.ts diff --git a/src/cli/runtime/transport.test.ts b/src/cli/runtime/transport.test.ts index c6d88d010d5..ddbe431248c 100644 --- a/src/cli/runtime/transport.test.ts +++ b/src/cli/runtime/transport.test.ts @@ -109,9 +109,9 @@ describe.skipIf(process.platform === 'win32')('runtime transport', () => { // A generous timeout: a passing fix rejects on close well before this; the // pre-fix behavior would hang the full duration and trip vitest's own limit. const start = Date.now() - await expect( - sendRequest(metadata, 'status.get', undefined, 60000) - ).rejects.toMatchObject({ code: 'runtime_unavailable' }) + await expect(sendRequest(metadata, 'status.get', undefined, 60000)).rejects.toMatchObject({ + code: 'runtime_unavailable' + }) expect(Date.now() - start).toBeLessThan(5000) }) }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d2a412df89f..fb7d2c9ae6f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -115,7 +115,11 @@ import type { VirtualizedScrollAnchor } from './hooks/useVirtualizedScrollAnchor import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-types' import type { OnboardingState } from '../../shared/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' -import { getFeatureTipsAppOpenDecision } from './components/feature-tips/feature-tip-startup-gate' +import { + getFeatureTipsAppOpenDecision, + isCliFeatureTipCompleted +} from './components/feature-tips/feature-tip-startup-gate' +import { trackOrcaCliFeatureTipShown } from './components/feature-tips/feature-tip-telemetry' import { keybindingMatchesAction, type KeybindingActionId, @@ -446,6 +450,7 @@ function App(): React.JSX.Element { const [onboarding, setOnboarding] = useState(null) const featureTipsPromptedThisSessionRef = useRef(false) const featureTipsSuppressedByOnboardingThisSessionRef = useRef(false) + const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState(null) const [onboardingSettingsDetour, setOnboardingSettingsDetour] = useState(false) const shouldRenderOnboarding = onboarding !== null && shouldShowOnboarding(onboarding) const onboardingSettingsDetourActive = @@ -486,9 +491,35 @@ function App(): React.JSX.Element { return onOnboardingReopened(setOnboarding) }, []) + useEffect(() => { + if (!persistedUIReady) { + return + } + + let cancelled = false + void window.api.cli + .getInstallStatus() + .then((status) => { + if (cancelled) { + return + } + setFeatureTipCliInstalled(isCliFeatureTipCompleted(status)) + }) + .catch(() => { + if (!cancelled) { + setFeatureTipCliInstalled(true) + } + }) + + return () => { + cancelled = true + } + }, [persistedUIReady]) + useEffect(() => { const featureTipsDecision = getFeatureTipsAppOpenDecision({ activeModal, + cliInstalled: featureTipCliInstalled, featureTipsSeenIds, featureInteractions, onboarding, @@ -510,6 +541,9 @@ function App(): React.JSX.Element { } featureTipsPromptedThisSessionRef.current = true + if (featureTipsDecision.tipId === 'orca-cli') { + trackOrcaCliFeatureTipShown('app_open') + } // Why: once a tip is visible, app quit/crash should not make it reappear // on the next launch just because the user never clicked a dismiss button. actions.markFeatureTipsSeen([featureTipsDecision.tipId]) @@ -517,6 +551,7 @@ function App(): React.JSX.Element { }, [ activeModal, actions, + featureTipCliInstalled, featureInteractions, featureTipsSeenIds, onboarding, diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index fc93e49c0e6..8032f870617 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1407,6 +1407,81 @@ transform-origin: center; } +/* CLI feature tip — terminal cursor inside the agent-control command. */ +@keyframes cli-tip-caret { + 0%, + 45% { + opacity: 1; + } + 46%, + 100% { + opacity: 0; + } +} + +.animate-cli-tip-caret { + animation: cli-tip-caret 0.9s steps(1, end) infinite; +} + +@keyframes cli-tip-command-line { + 0% { + opacity: 0; + transform: translateY(2px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.animate-cli-tip-command-line { + animation: cli-tip-command-line 160ms ease-out both; +} + +@media (prefers-reduced-motion: reduce) { + .animate-cli-tip-caret { + animation: none; + opacity: 1; + transform: none; + } + + .animate-cli-tip-command-line { + animation: none; + } +} + +.cli-tip-orchestration-frame .feature-wall-orch-stage { + --feature-wall-workspace-title-size: 11.5px; + --feature-wall-agent-message-size: 10.5px; + --feature-wall-agent-row-gap: 5px; + --feature-wall-agent-status-col: 10px; + --feature-wall-agent-icon-col: 12px; + --feature-wall-agent-status-box: 10px; + --feature-wall-agent-status-icon: 9px; + --feature-wall-agent-icon-box: 12px; + --feature-wall-child-indent: 22px; + align-content: start !important; + padding-top: 10px !important; + padding-left: 12px !important; + padding-right: 52px !important; +} + +.cli-tip-orchestration-frame .feature-wall-agent-icon svg { + height: 11px; + width: 11px; +} + +.cli-tip-orchestration-frame .feature-wall-agent-status [aria-label] { + height: 10px; + width: 10px; +} + +.cli-tip-orchestration-frame .feature-wall-agent-status [aria-label] > span { + height: 6px; + width: 6px; + border-width: 1.5px; +} + /* ── Feature wall: agents-orchestration animations ───────────────── */ /* Why: drives the supported-agents marquee on page 1 of the agents tile. @@ -1509,7 +1584,7 @@ offset-distance: 100%; transform: translate(-12px, -12px) scale(1); transition: - offset-distance 1500ms cubic-bezier(0.25, 0.6, 0.25, 1), + offset-distance 1600ms cubic-bezier(0.25, 0.6, 0.25, 1), transform 280ms ease, opacity 280ms ease; } @@ -1537,37 +1612,6 @@ animation: feature-wall-msg-pop 1100ms ease-out; } -@keyframes feature-wall-orch-spin { - to { - transform: rotate(360deg); - } -} -.feature-wall-spawn-spinner { - width: 11px; - height: 11px; - border: 1.5px solid rgb(245 158 11); - border-top-color: transparent; - border-radius: 999px; - animation: feature-wall-orch-spin 800ms linear infinite; -} - -/* "Creating workspaces…" placeholder shown before the child workspaces - reveal. Once the orchestrator finishes spawning, the wrapper collapses - and the real child cards take its place. */ -.feature-wall-creating-children { - overflow: hidden; - opacity: 1; - max-height: 60px; - transition: - opacity 220ms ease, - max-height 320ms cubic-bezier(0.4, 0, 0.2, 1); -} -.feature-wall-creating-children[data-hidden='true'] { - opacity: 0; - max-height: 0; - pointer-events: none; -} - /* Children container fades in once the orchestrator finishes creating. */ .feature-wall-children-wrapper { opacity: 0; @@ -1581,6 +1625,20 @@ transform: none; } +@keyframes feature-wall-child-card-in { + 0% { + opacity: 0; + transform: translateY(4px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} +.feature-wall-child-card-shell { + animation: feature-wall-child-card-in 260ms ease-out both; +} + /* Each child agent row stays collapsed until *its* dispatch bubble lands on it, so the child workspace visibly gains an agent as work arrives. */ .feature-wall-spawn-row { diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 456c3fc5cb1..0a1de13d701 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -191,6 +191,7 @@ function findBrowserSelection( function getSettingsTargetFromSectionId(sectionId: string): { pane: SettingsNavTarget repoId: string | null + sectionId?: string } { if (sectionId.startsWith('repo-')) { return { pane: 'repo', repoId: sectionId.slice('repo-'.length) } @@ -831,6 +832,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const handleSelectSettings = useCallback( (result: CmdJSettingsResult) => { const target = getSettingsTargetFromSectionId(result.sectionId) + if (result.targetSectionId) { + target.sectionId = result.targetSectionId + } skipRestoreFocusRef.current = true closeModal() setSelectedItemId('') diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index 3ad5928f4ca..4c3bfb0b542 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -65,6 +65,22 @@ const actions: CmdJQuickAction[] = [ ] const sections: SettingsNavSection[] = [ + { + id: 'general', + title: 'General', + description: 'Workspace defaults.', + icon: Settings, + searchEntries: [ + { + title: 'Orca CLI', + description: 'Register or remove the orca shell command.', + keywords: ['cli', 'path', 'terminal', 'command', 'shell command'], + cmdJKeywords: ['cli', 'path', 'command', 'shell command'], + targetSectionId: 'cli' + } + ], + group: 'setup' + }, { id: 'terminal', title: 'Terminal', @@ -141,11 +157,26 @@ describe('Cmd+J palette middle-band ranking', () => { ['terminal', 'settings:terminal'], ['browser', 'settings:browser'], ['quick commands', 'settings:quick-commands'], - ['add quick command', 'add-quick-command'] + ['add quick command', 'add-quick-command'], + ['orca cli', 'settings:general:cli'], + ['shell command', 'settings:general:cli'] ])('ranks %s first', (query, expectedId) => { expect(top(query)).toBe(expectedId) }) + it('builds targeted settings rows for Settings subsections', () => { + const cliResult = buildCmdJSettingsResults(sections).find( + (result) => result.id === 'settings:general:cli' + ) + + expect(cliResult).toMatchObject({ + title: 'Orca CLI', + description: 'Register or remove the orca shell command.', + sectionId: 'general', + targetSectionId: 'cli' + }) + }) + it('does not match settings on one-character or description-only queries', () => { expect(top('t')).toBeUndefined() expect(top('cookie import')).toBeUndefined() diff --git a/src/renderer/src/components/cmd-j/palette-results.ts b/src/renderer/src/components/cmd-j/palette-results.ts index 90056df6a78..f6295856569 100644 --- a/src/renderer/src/components/cmd-j/palette-results.ts +++ b/src/renderer/src/components/cmd-j/palette-results.ts @@ -9,6 +9,7 @@ export type CmdJSettingsResult = { description: string icon: LucideIcon sectionId: string + targetSectionId?: string order: number configKeywords: string[] } @@ -50,6 +51,7 @@ function normalizeQuery(value: string): string { function keywordParts(section: SettingsNavSection): string[] { const baseId = section.id.startsWith('repo-') ? 'repo' : section.id const idWords = baseId.replace(/-/g, ' ') + const paneLevelEntries = section.searchEntries.filter((entry) => !entry.targetSectionId) return [ section.id, baseId, @@ -58,10 +60,14 @@ function keywordParts(section: SettingsNavSection): string[] { `${section.title} settings`, `${idWords} settings`, ...(SETTINGS_ALIASES[baseId] ?? []), - ...section.searchEntries.map((entry) => entry.title) + ...paneLevelEntries.map((entry) => entry.title) ] } +function targetEntryKeywordParts(entryTitle: string): string[] { + return [entryTitle, `${entryTitle} settings`] +} + function uniqueNormalized(values: readonly string[]): string[] { return [...new Set(values.map(normalizeQuery).filter(Boolean))] } @@ -69,16 +75,36 @@ function uniqueNormalized(values: readonly string[]): string[] { export function buildCmdJSettingsResults( sections: readonly SettingsNavSection[] ): CmdJSettingsResult[] { - return sections.map((section, order) => ({ - id: `settings:${section.id}`, - kind: 'settings', - title: section.title, - description: section.description, - icon: section.icon, - sectionId: section.id, - order, - configKeywords: uniqueNormalized(keywordParts(section)) - })) + return sections.flatMap((section, order) => { + const paneResult: CmdJSettingsResult = { + id: `settings:${section.id}`, + kind: 'settings', + title: section.title, + description: section.description, + icon: section.icon, + sectionId: section.id, + order, + configKeywords: uniqueNormalized(keywordParts(section)) + } + const targetedResults = section.searchEntries + .filter((entry) => entry.targetSectionId) + .map((entry, entryIndex) => ({ + id: `settings:${section.id}:${entry.targetSectionId}`, + kind: 'settings' as const, + title: entry.title, + description: entry.description ?? section.description, + icon: section.icon, + sectionId: section.id, + targetSectionId: entry.targetSectionId, + order: order + (entryIndex + 1) / 100, + configKeywords: uniqueNormalized([ + ...targetEntryKeywordParts(entry.title), + ...(entry.cmdJKeywords ?? entry.keywords ?? []) + ]) + })) + + return [paneResult, ...targetedResults] + }) } export function buildCmdJActionResults(actions: readonly CmdJQuickAction[]): CmdJActionResult[] { diff --git a/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx b/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx index 5f672dc3116..2371be28314 100644 --- a/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx +++ b/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx @@ -1,9 +1,22 @@ -import type { JSX } from 'react' -import { Mic, Sparkles } from 'lucide-react' +import { useEffect, useState, type JSX } from 'react' +import { Loader2, Mic } from 'lucide-react' +import { toast } from 'sonner' import { getDefaultVoiceSettings } from '../../../../shared/constants' import type { FeatureTip } from '../../../../shared/feature-tips' -import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { AgentsOrchestrationVisual } from '@/components/feature-wall/AgentsOrchestrationVisual' +import { + ORCHESTRATION_CLI_COMMAND_LOOP_MS, + ORCHESTRATION_CLI_COMMAND_TIMINGS_MS +} from '@/components/feature-wall/agents-orchestration/orchestration-types' +import { usePrefersReducedMotion } from '@/components/feature-wall/feature-wall-modal-helpers' +import { OnboardingInlineCommandTerminal } from '@/components/onboarding/OnboardingInlineCommandTerminal' +import { ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands' +import { + ORCHESTRATION_ENABLED_STORAGE_KEY, + ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY, + notifyOrchestrationSetupStateChanged +} from '@/lib/orchestration-setup-state' import { Dialog, DialogContent, @@ -13,11 +26,114 @@ import { DialogTitle } from '@/components/ui/dialog' import { useAppStore } from '@/store' +import { installCliFromFeatureTip } from './feature-tip-cli-install-action' import { getFeatureTipForModal } from './feature-tip-modal-state' +import { + getOrcaCliFeatureTipTelemetrySource, + trackOrcaCliFeatureTipSetupClicked, + trackOrcaCliFeatureTipSetupResult +} from './feature-tip-telemetry' const WAVEFORM_BAR_HEIGHTS = [30, 60, 90, 70, 100, 50, 80, 35, 65] +const CLI_AGENT_COMMANDS = [ + 'orca worktree create --name auth-pr-1', + 'orca worktree create --name auth-pr-2', + 'orca orchestration dispatch --task pr1 --to w1', + 'orca orchestration dispatch --task pr2 --to w2' +] + +function CliFeatureTipVisual(): JSX.Element { + const reducedMotion = usePrefersReducedMotion() + const [visibleCommandCount, setVisibleCommandCount] = useState( + reducedMotion ? CLI_AGENT_COMMANDS.length : 0 + ) + + useEffect(() => { + if (reducedMotion) { + setVisibleCommandCount(CLI_AGENT_COMMANDS.length) + return + } + + let cancelled = false + const timeouts: number[] = [] + const later = (fn: () => void, ms: number): void => { + timeouts.push(window.setTimeout(() => !cancelled && fn(), ms)) + } + + // Why: terminal lines mirror the orchestration tour beat timings so the + // shell shows each command as the parent agent runs it. + const runOnce = (): void => { + setVisibleCommandCount(0) + ORCHESTRATION_CLI_COMMAND_TIMINGS_MS.forEach((ms, index) => { + later(() => setVisibleCommandCount(index + 1), ms) + }) + later(runOnce, ORCHESTRATION_CLI_COMMAND_LOOP_MS) + } + + runOnce() + return () => { + cancelled = true + timeouts.forEach((id) => window.clearTimeout(id)) + } + }, [reducedMotion]) + + return ( + diff --git a/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-cards.tsx b/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-cards.tsx index 1196eb0335b..69fb4cd816f 100644 --- a/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-cards.tsx +++ b/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-cards.tsx @@ -35,10 +35,13 @@ export function WorkspaceCard(props: { /> {name} @@ -62,20 +65,53 @@ export function AgentRow(props: { return (
- + {state === 'working' ? ( ) : ( - - + + )} - {icon} + + {icon} + 0 && 'feature-wall-msg-received' )} + style={{ fontSize: 'var(--feature-wall-agent-message-size, 13px)' }} > {message} diff --git a/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-types.ts b/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-types.ts index c26ba26f64a..6f10f85697b 100644 --- a/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-types.ts +++ b/src/renderer/src/components/feature-wall/agents-orchestration/orchestration-types.ts @@ -2,6 +2,8 @@ export const BUBBLE_FLIGHT_MS = 1600 export const BUBBLE_LAND_MS = BUBBLE_FLIGHT_MS + 360 export const BUBBLE_GAP_MS = 3400 +export const ORCHESTRATION_CLI_COMMAND_TIMINGS_MS = [250, 2500, 5200, 8600] as const +export const ORCHESTRATION_CLI_COMMAND_LOOP_MS = 12800 export type AgentKey = 'coord-claude' | 'child-codex' | 'child-claude' diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index dab0902b1a5..9b07547cca9 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -17,6 +17,7 @@ type OnboardingInlineCommandTerminalProps = { ariaLabel: string terminalHeightPx?: number terminalTopMarginPx?: number + descriptionPaddingClassName?: string autoScrollIntoView?: boolean worktreeId?: string onOpened?: () => void @@ -30,6 +31,7 @@ export function OnboardingInlineCommandTerminal({ ariaLabel, terminalHeightPx = 280, terminalTopMarginPx = 20, + descriptionPaddingClassName = 'px-4 py-3', autoScrollIntoView = true, worktreeId = ONBOARDING_INLINE_TERMINAL_WORKTREE_ID, onOpened, @@ -225,7 +227,7 @@ export function OnboardingInlineCommandTerminal({ className="min-h-0 overflow-hidden rounded-xl border border-border bg-card" > {description ? ( -
+

{description}

) : null} diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 30b9128e959..bc332e73610 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -120,7 +120,7 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem } return ( -
+

Orca CLI

diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts index 6a5ffbcb32c..332dbd68c15 100644 --- a/src/renderer/src/components/settings/general-search.ts +++ b/src/renderer/src/components/settings/general-search.ts @@ -80,9 +80,11 @@ export const GENERAL_NAVIGATION_SEARCH_ENTRIES: SettingsSearchEntry[] = [ export const GENERAL_CLI_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { - title: 'Shell command', + title: 'Orca CLI', description: 'Register or remove the orca shell command.', - keywords: ['cli', 'path', 'terminal', 'command'] + keywords: ['cli', 'path', 'terminal', 'command', 'shell command'], + cmdJKeywords: ['cli', 'path', 'command', 'shell command'], + targetSectionId: 'cli' }, { title: 'Agent skill', diff --git a/src/renderer/src/components/settings/settings-search.ts b/src/renderer/src/components/settings/settings-search.ts index c72750bf546..59abab8a2b2 100644 --- a/src/renderer/src/components/settings/settings-search.ts +++ b/src/renderer/src/components/settings/settings-search.ts @@ -2,6 +2,8 @@ export type SettingsSearchEntry = { title: string description?: string keywords?: string[] + cmdJKeywords?: string[] + targetSectionId?: string } export function normalizeSettingsSearchQuery(query: string): string { diff --git a/src/renderer/src/lib/agent-feature-install-commands.ts b/src/renderer/src/lib/agent-feature-install-commands.ts index 6607c22cd7d..030996c80eb 100644 --- a/src/renderer/src/lib/agent-feature-install-commands.ts +++ b/src/renderer/src/lib/agent-feature-install-commands.ts @@ -3,6 +3,7 @@ export { COMPUTER_USE_SKILL_INSTALL_COMMAND, COMPUTER_USE_SKILL_NAME, ORCA_CLI_SKILL_INSTALL_COMMAND, + ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND, ORCA_CLI_SKILL_NAME, ORCHESTRATION_SKILL_NAME } from '../../../shared/agent-feature-install-commands' diff --git a/src/shared/agent-feature-install-commands.ts b/src/shared/agent-feature-install-commands.ts index 0b9a4ed5826..fe9d227b3af 100644 --- a/src/shared/agent-feature-install-commands.ts +++ b/src/shared/agent-feature-install-commands.ts @@ -22,3 +22,8 @@ export const COMPUTER_USE_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallC export const ORCHESTRATION_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([ ORCHESTRATION_SKILL_NAME ]) + +export const ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([ + ORCA_CLI_SKILL_NAME, + ORCHESTRATION_SKILL_NAME +]) diff --git a/src/shared/feature-tips.test.ts b/src/shared/feature-tips.test.ts index 56365ab72fd..f4737720676 100644 --- a/src/shared/feature-tips.test.ts +++ b/src/shared/feature-tips.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + FEATURE_TIPS, getCompletedFeatureTipIds, getOrderedUnseenFeatureTips, normalizeFeatureTipIds, @@ -10,12 +11,12 @@ describe('feature tips', () => { it('orders new unseen tips before older unseen tips', () => { const tips = getOrderedUnseenFeatureTips({ seenTipIds: new Set() }) - expect(tips.map((tip) => tip.id)).toEqual(['voice-dictation']) + expect(tips.map((tip) => tip.id)).toEqual(['orca-cli', 'voice-dictation']) }) it('skips tips the user has already seen', () => { const tips = getOrderedUnseenFeatureTips({ - seenTipIds: new Set(['voice-dictation']) + seenTipIds: new Set(['voice-dictation', 'orca-cli']) }) expect(tips.map((tip) => tip.id)).toEqual([]) @@ -24,7 +25,22 @@ describe('feature tips', () => { it('skips tips for features the user has already completed', () => { const tips = getOrderedUnseenFeatureTips({ seenTipIds: new Set(), - completedTipIds: getCompletedFeatureTipIds({ voiceDictationEnabled: true }) + completedTipIds: getCompletedFeatureTipIds({ + cliInstalled: true, + voiceDictationEnabled: true + }) + }) + + expect(tips.map((tip) => tip.id)).toEqual([]) + }) + + it('skips the CLI tip when the CLI is already installed', () => { + const tips = getOrderedUnseenFeatureTips({ + seenTipIds: new Set(['voice-dictation']), + completedTipIds: getCompletedFeatureTipIds({ + cliInstalled: true, + voiceDictationEnabled: false + }) }) expect(tips.map((tip) => tip.id)).toEqual([]) @@ -34,6 +50,7 @@ describe('feature tips', () => { const tips = getOrderedUnseenFeatureTips({ seenTipIds: new Set(), completedTipIds: getCompletedFeatureTipIds({ + cliInstalled: false, voiceDictationEnabled: false, featureInteractions: { 'voice-dictation': { firstInteractedAt: 100, interactionCount: 1 } @@ -41,12 +58,31 @@ describe('feature tips', () => { }) }) - expect(tips.map((tip) => tip.id)).toEqual([]) + expect(tips.map((tip) => tip.id)).toEqual(['orca-cli']) }) it('normalizes persisted tip ids', () => { - expect(normalizeFeatureTipIds(['feature-tour', 'bogus', 'voice-dictation'])).toEqual([ - 'voice-dictation' - ]) + expect( + normalizeFeatureTipIds(['feature-tour', 'orca-cli', 'bogus', 'voice-dictation']) + ).toEqual(['orca-cli', 'voice-dictation']) + }) + + it('describes the CLI tip as an install action with concrete workflows', () => { + const cliTip = FEATURE_TIPS.find((tip) => tip.id === 'orca-cli') + + expect(cliTip).toMatchObject({ + action: 'setup-cli', + title: 'Let agents drive Orca with the Orca CLI', + ctaLabel: 'Install CLI & Skills' + }) + expect(cliTip?.description).toContain('coordinate child workspaces') + expect(cliTip?.description).toContain('communicate between workspaces') + }) + + it('does not label the voice dictation tip as new', () => { + const voiceTip = FEATURE_TIPS.find((tip) => tip.id === 'voice-dictation') + + expect(voiceTip?.eyebrow).toBe('Tip') + expect(voiceTip?.priority).toBe('unseen') }) }) diff --git a/src/shared/feature-tips.ts b/src/shared/feature-tips.ts index a4b8bc3713b..7db691fe9d1 100644 --- a/src/shared/feature-tips.ts +++ b/src/shared/feature-tips.ts @@ -4,11 +4,11 @@ import { type FeatureInteractionState } from './feature-interactions' -export type FeatureTipId = 'voice-dictation' +export type FeatureTipId = 'voice-dictation' | 'orca-cli' export type FeatureTipPriority = 'new' | 'unseen' -export type FeatureTipAction = 'enable-voice' +export type FeatureTipAction = 'enable-voice' | 'setup-cli' export type FeatureTip = { id: FeatureTipId @@ -23,15 +23,26 @@ export type FeatureTip = { } export type CompletedFeatureTipState = { + cliInstalled: boolean voiceDictationEnabled: boolean featureInteractions?: FeatureInteractionState } export const FEATURE_TIPS = [ { - id: 'voice-dictation', + id: 'orca-cli', priority: 'new', - eyebrow: 'New', + eyebrow: 'Tip', + title: 'Let agents drive Orca with the Orca CLI', + description: 'Enable agents to coordinate child workspaces and communicate between workspaces.', + action: 'setup-cli', + ctaLabel: 'Install CLI & Skills', + completedByFeatureInteractions: [] + }, + { + id: 'voice-dictation', + priority: 'unseen', + eyebrow: 'Tip', title: 'Voice Dictation is here', description: 'Speak into any focused pane and Orca will transcribe it. Press the dictation shortcut to start and stop.', @@ -63,6 +74,9 @@ export function normalizeFeatureTipIds(value: unknown): FeatureTipId[] { export function getCompletedFeatureTipIds(state: CompletedFeatureTipState): Set { const completedIds = new Set() + if (state.cliInstalled) { + completedIds.add('orca-cli') + } if (state.voiceDictationEnabled) { completedIds.add('voice-dictation') } diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index 29e91721643..90924dd5033 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -265,6 +265,46 @@ describe('settings_changed schema', () => { }) }) +describe('orca cli feature tip schemas', () => { + it('accepts the shown event for app-open exposure', () => { + const parsed = eventSchemas.orca_cli_feature_tip_shown.safeParse({ + source: 'app_open' + }) + + expect(parsed.success).toBe(true) + }) + + it('accepts setup click and setup result events', () => { + expect( + eventSchemas.orca_cli_feature_tip_setup_clicked.safeParse({ + source: 'app_open' + }).success + ).toBe(true) + expect( + eventSchemas.orca_cli_feature_tip_setup_result.safeParse({ + source: 'app_open', + result: 'installed' + }).success + ).toBe(true) + }) + + it('rejects raw CLI details and unknown result values', () => { + expect( + eventSchemas.orca_cli_feature_tip_setup_result.safeParse({ + source: 'app_open', + result: 'installed', + command_path: '/Users/alice/bin/orca' + }).success + ).toBe(false) + expect( + eventSchemas.orca_cli_feature_tip_setup_result.safeParse({ + source: 'app_open', + result: 'installed_after_retry' + }).success + ).toBe(false) + }) +}) + describe('commonPropsSchema', () => { it('round-trips a realistic payload', () => { const parsed = commonPropsSchema.safeParse({ diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index fb62716ceeb..fe00d07f9c8 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -323,6 +323,27 @@ const settingsChangedSchema = z const telemetryOptedInSchema = z.object({ via: optInViaSchema }).strict() const telemetryOptedOutSchema = z.object({ via: optInViaSchema }).strict() +const orcaCliFeatureTipSourceSchema = z.enum(['app_open', 'manual']) +const orcaCliFeatureTipShownSchema = z + .object({ + source: orcaCliFeatureTipSourceSchema, + nth_repo_added: nthRepoAddedSchema + }) + .strict() +const orcaCliFeatureTipSetupClickedSchema = z + .object({ + source: orcaCliFeatureTipSourceSchema, + nth_repo_added: nthRepoAddedSchema + }) + .strict() +const orcaCliFeatureTipSetupResultSchema = z + .object({ + source: orcaCliFeatureTipSourceSchema, + result: z.enum(['installed', 'needs_attention', 'dev_preview', 'failed']), + nth_repo_added: nthRepoAddedSchema + }) + .strict() + const featureWallOpenedSchema = z .object({ source: featureWallOpenSourceSchema @@ -1046,6 +1067,10 @@ export const eventSchemas = { telemetry_opted_in: telemetryOptedInSchema, telemetry_opted_out: telemetryOptedOutSchema, + orca_cli_feature_tip_shown: orcaCliFeatureTipShownSchema, + orca_cli_feature_tip_setup_clicked: orcaCliFeatureTipSetupClickedSchema, + orca_cli_feature_tip_setup_result: orcaCliFeatureTipSetupResultSchema, + feature_wall_opened: featureWallOpenedSchema, feature_wall_closed: featureWallClosedSchema, feature_wall_tile_focused: featureWallTileFocusedSchema, @@ -1130,6 +1155,9 @@ type _CohortExtendedRoster = | 'agent_started' | 'agent_prompt_sent' | 'agent_error' + | 'orca_cli_feature_tip_shown' + | 'orca_cli_feature_tip_setup_clicked' + | 'orca_cli_feature_tip_setup_result' // Why: `z.object({}).strict()` infers a string index signature, which would // make every key appear present. Ignore index-signature-only keys here so // strict empty event payloads do not get pulled into keyed telemetry rosters. diff --git a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts index 8127616b7fb..74bb5494a16 100644 --- a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts +++ b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts @@ -1,6 +1,6 @@ import { ONBOARDING_FINAL_STEP } from '../../../src/shared/constants' -const SEEN_FIRST_RUN_FEATURE_TIP_IDS = ['voice-dictation'] as const +const SEEN_FIRST_RUN_FEATURE_TIP_IDS = ['voice-dictation', 'orca-cli'] as const export function getE2ECompletedOnboardingProfile() { return { From a1496d54d40cf6d06f3e5f7f2ebe44e13487cde5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 16:25:10 -0700 Subject: [PATCH 022/224] Document upstream React effect baseline (#3369) --- docs/reference/react-performance-audit.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index 12bfe36f779..41a6d1cb27a 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -47,13 +47,13 @@ Initial inventory: ## Coverage Ledger -Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026: 911 Effect hook call sites. +Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026 and upstream Orca CLI feature-tip changes #3279: 914 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, #3349, #3354, #3356, #3361, and #3365 each project to 910 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 909; open medium-risk PRs #3114, #3168, and #3259 each project to 908; open medium-risk PRs #3095, #3140, and #3230 each project to 907; open medium-risk PR #3326 projects to 902; open medium-risk PR #3079 projects to 901; open high-risk PRs #3157, #3212, #3253, #3351, and #3367 each project to 910; open high-risk PR #3267 projects to 909; open high-risk PR #3075 projects to 907; open high-risk PR #3250 projects to 906; and open high-risk PR #3081 projects to 903. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, #3349, #3354, #3356, #3361, and #3365 each project to 913 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 912; open medium-risk PRs #3114, #3168, and #3259 each project to 911; open medium-risk PRs #3095, #3140, and #3230 each project to 910; open medium-risk PR #3326 projects to 905; open medium-risk PR #3079 projects to 904; open high-risk PRs #3157, #3212, #3253, #3351, and #3367 each project to 913; open high-risk PR #3267 projects to 912; open high-risk PR #3075 projects to 910; open high-risk PR #3250 projects to 909; and open high-risk PR #3081 projects to 906. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| Renderer app shell | `src/renderer/src/App.tsx`, root components | Inventory complete, manual review in progress | Confirmation dialog active request mirror covered by #3091; onboarding settings detour reset covered by #3122; quick workspace agent selection repair covered by #3148; Cmd+J selection repair covered by #3164; smart workspace command highlight covered by #3192; App lazy-modal mount bookkeeping covered by #3220; shared reduced-motion media-query hook covered by #3287; tab-entry selected action reset covered by #3311. Continue checking global listeners, beforeunload, media-query, and active-tab repair. | +| Renderer app shell | `src/renderer/src/App.tsx`, root components | Inventory complete, manual review in progress | Confirmation dialog active request mirror covered by #3091; onboarding settings detour reset covered by #3122; quick workspace agent selection repair covered by #3148; Cmd+J selection repair covered by #3164; smart workspace command highlight covered by #3192; App lazy-modal mount bookkeeping covered by #3220; shared reduced-motion media-query hook covered by #3287; tab-entry selected action reset covered by #3311; upstream #3279 added feature-tip startup gating to inspect. Continue checking global listeners, beforeunload, media-query, and active-tab repair. | | Terminal / PTY | `components/Terminal.tsx`, `components/terminal-pane/**`, `components/terminal/**` | Inventory complete, manual review pending | Mobile driver overlay collapse repair covered by #3170; floating workspace first-open bounds normalization covered by #3269; unused legacy terminal shell/shortcut/hook files deleted by #3271; terminal tab rename focus covered by #3314 without PTY protocol changes; terminal search focus covered by #3344; Activity terminal portal subscription covered by #3367. High-risk area: xterm lifecycle, scrollback, remote/mobile parity, focus, WebGL, resize. | | Renderer mobile page | `components/mobile/**` | Inventory complete, manual review in progress | Mobile pairing page stage repair covered by #3180. Continue checking QR generation, mobile-page IPC, and paired-device polling. | | Browser pane | `components/browser-pane/**` | Inventory complete, manual review in progress | Highest Effect density: 62 sites in `BrowserPane.tsx`. Browser ref mirrors covered by #3081; BrowserFind debounced-query state covered by #3275. Continue with driver sync, address bar derived state, remaining find state, webview lifetime. | @@ -64,7 +64,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Settings | `components/settings/**` | Inventory complete, manual review in progress | Repository hook local editor visibility covered by #3093; lazy section mounted-state bookkeeping covered by #3128; browser home/Kagi draft sync covered by #3136; general auto-save/Open In draft sync covered by #3138; Source Control AI instruction drafts covered by #3140; notification volume draft sync covered by #3142; repo Source Control AI override draft sync covered by #3144; Quick Commands add intent covered by #3146; Mobile QR polling count capture covered by #3152; integration preflight status derivation covered by #3154; SSH reset-relay dialog cleanup covered by #3157; diagnostics bundle cleanup ref mirror covered by #3176; font autocomplete option scroll covered by #3349; SSH passphrase prompt focus covered by #3351. Continue with draft mirrors while keeping SSH and cross-platform settings behavior intact. | | Automations | `components/automations/**` | Inventory complete, manual review in progress | Combobox open-focus Effects covered by #3100; run-history selection repair covered by #3104; external run-table selection repair covered by #3102; detail-page selection resets covered by #3106; create-from branch search reset covered by #3132. Continue with draft defaulting, polling, and completion detection. | | Issue, PR, task pages | `TaskPage.tsx`, `PullRequestPage.tsx`, `GitHubItemDialog.tsx`, `GitLabItemDialog.tsx`, Linear components | Inventory complete, manual review in progress | Linear estimate input sync covered by #3108; Linear sub-issue list sync covered by #3110; GitHub project date draft sync covered by #3112; GitHub reviewer-picker active index covered by #3114; GitHub project column state covered by #3116; GitHub Project visible-table cache covered by #3186; GitHub Project dialog state covered by #3214; GitLab comment draft reset covered by #3124; Linear title/description draft sync covered by #3130; GitHub assignee dirty-flag reset covered by #3255; GitHub reviewer-request local sync covered by #3259; GitHub link-copy feedback timers covered by #3285; GitLab task filter repair covered by #3292. Continue large-file scan with separate GitHub, GitLab, Linear, and generic review behavior. | -| Onboarding / feature wall | `components/onboarding/**`, `components/feature-wall/**` | Inventory complete, manual review in progress | Select portal root Effects covered by #3087; tour visited-marker Effects covered by #3095; persisted completion close reset covered by #3188; onboarding settings hydration covered by #3198; unused orchestration notifications page deleted by #3273; feature-wall skill installed-state notification Effects covered by #3281; session-depth open publish Effects covered by #3283; shared reduced-motion media-query hook covered by #3287. Continue with demo timers and telemetry while avoiding telemetry semantic changes. | +| Onboarding / feature wall | `components/onboarding/**`, `components/feature-wall/**` | Inventory complete, manual review in progress | Select portal root Effects covered by #3087; tour visited-marker Effects covered by #3095; persisted completion close reset covered by #3188; onboarding settings hydration covered by #3198; unused orchestration notifications page deleted by #3273; feature-wall skill installed-state notification Effects covered by #3281; session-depth open publish Effects covered by #3283; shared reduced-motion media-query hook covered by #3287; upstream #3279 added feature-tip modal and orchestration visual Effects to inspect. Continue with demo timers and telemetry while avoiding telemetry semantic changes. | | Status, dashboard, activity | `components/status-bar/**`, `components/dashboard/**`, `components/activity/**`, `components/pet/**` | Inventory complete, manual review in progress | Status-bar account menu close reset covered by #3051; pet overlay size clamp covered by #3118; Activity selected-thread repair covered by #3126; Workspace Space row-state repairs covered by #3168; Resource Manager Space scan-ready latch covered by #3200; pet reduced-motion subscription covered by #3287; status-bar account usage fetch covered by #3340; Space page open interaction covered by #3361. Check interval sharing, retained agent state, activity terminal portals. | | Mobile app routes | `mobile/app/**` | Inventory complete, manual review in progress | Pair-confirm route-code parsing covered by #3240; host home new-worktree route action covered by #3242; Accounts unused client ref covered by #3244; mobile home shared-client ref mirror covered by #3247; session route terminal/tab ref mirrors covered by #3250; tasks route client ref mirror covered by #3253; tasks route workspace agent selection covered by #3261. Continue with large `tasks.tsx` state/polling Effects, session route cleanup Effects, home route ref mirrors, and remote-client parity. | | Mobile shared source | `mobile/src/**` | Inventory complete, manual review in progress | TextInputModal and CustomKeyModal open-state resets covered by #3223; BottomDrawer mount bookkeeping covered by #3226; MobileBrowserPane address sync covered by #3228; MobileBrowserPane ref mirrors covered by #3230; useMobileDictation ref mirrors covered by #3234; NewWorktreeModal agent selection covered by #3238. Continue with transport client context plus NewWorktreeModal open/reset, SSH, agent-detection, and setup-hook fetch Effects. | From 425dee5085078f9730d8d396effa04aff77123f0 Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 16:29:40 -0700 Subject: [PATCH 023/224] fix: pr-bug-scan validated finding from #2793 (#3024) Co-authored-by: orca-bug-scan-bot Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../components/right-sidebar/FileExplorer.tsx | 11 +++++++++ .../right-sidebar/file-explorer-reset.test.ts | 24 +++++++++++++++++++ .../right-sidebar/file-explorer-reset.ts | 6 +++++ 3 files changed, 41 insertions(+) create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-reset.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-reset.ts diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 303ffef219a..6809f3cc642 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -8,6 +8,7 @@ import { folderRelativePathToIncludeGlob } from './file-search-include-pattern' import { ScrollArea } from '@/components/ui/scroll-area' import { cn } from '@/lib/utils' import { isGitRepoKind } from '../../../../shared/repo-kind' +import { shouldResetFileExplorerForVisibleWorktree } from './file-explorer-reset' import { FileExplorerBackgroundMenu } from './FileExplorerBackgroundMenu' import { FileExplorerToolbar } from './FileExplorerToolbar' import { FileExplorerTreeStatus } from './FileExplorerTreeStatus' @@ -157,12 +158,22 @@ function FileExplorerInner(): React.JSX.Element { scrollRef }) + const lastResetWorktreePathRef = useRef(null) useEffect(() => { if (!visibleWorktreePath) { return } // Why: the sidebar remains mounted while closed to preserve caches, but // loading the hidden tree would probe every clicked workspace on macOS. + if ( + !shouldResetFileExplorerForVisibleWorktree( + lastResetWorktreePathRef.current, + visibleWorktreePath + ) + ) { + return + } + lastResetWorktreePathRef.current = visibleWorktreePath resetSelection() resetAndLoad() clearFileExplorerUndoHistory() diff --git a/src/renderer/src/components/right-sidebar/file-explorer-reset.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-reset.test.ts new file mode 100644 index 00000000000..66a341c4c9d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-reset.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { shouldResetFileExplorerForVisibleWorktree } from './file-explorer-reset' + +describe('shouldResetFileExplorerForVisibleWorktree', () => { + it('preserves explorer state across hide and reopen of the same worktree', () => { + let lastResetWorktreePath: string | null = null + const shouldReset = (visibleWorktreePath: string | null): boolean => { + if (shouldResetFileExplorerForVisibleWorktree(lastResetWorktreePath, visibleWorktreePath)) { + lastResetWorktreePath = visibleWorktreePath + return true + } + return false + } + + expect(shouldReset(null)).toBe(false) + expect(shouldReset('/repo')).toBe(true) + expect(shouldReset(null)).toBe(false) + expect(shouldReset('/repo')).toBe(false) + }) + + it('resets when the visible worktree path changes', () => { + expect(shouldResetFileExplorerForVisibleWorktree('/repo', '/repo-next')).toBe(true) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-reset.ts b/src/renderer/src/components/right-sidebar/file-explorer-reset.ts new file mode 100644 index 00000000000..2473fe9a874 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-reset.ts @@ -0,0 +1,6 @@ +export function shouldResetFileExplorerForVisibleWorktree( + lastResetWorktreePath: string | null, + visibleWorktreePath: string | null +): visibleWorktreePath is string { + return visibleWorktreePath !== null && lastResetWorktreePath !== visibleWorktreePath +} From a99c376f3bd0b219d992786bc3c12743b2a0a037 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 16:32:21 -0700 Subject: [PATCH 024/224] Document upstream feature tip effect audit (#3372) --- docs/reference/react-performance-audit.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index 41a6d1cb27a..8a2529da1e1 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -53,7 +53,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| Renderer app shell | `src/renderer/src/App.tsx`, root components | Inventory complete, manual review in progress | Confirmation dialog active request mirror covered by #3091; onboarding settings detour reset covered by #3122; quick workspace agent selection repair covered by #3148; Cmd+J selection repair covered by #3164; smart workspace command highlight covered by #3192; App lazy-modal mount bookkeeping covered by #3220; shared reduced-motion media-query hook covered by #3287; tab-entry selected action reset covered by #3311; upstream #3279 added feature-tip startup gating to inspect. Continue checking global listeners, beforeunload, media-query, and active-tab repair. | +| Renderer app shell | `src/renderer/src/App.tsx`, root components | Inventory complete, manual review in progress | Confirmation dialog active request mirror covered by #3091; onboarding settings detour reset covered by #3122; quick workspace agent selection repair covered by #3148; Cmd+J selection repair covered by #3164; smart workspace command highlight covered by #3192; App lazy-modal mount bookkeeping covered by #3220; shared reduced-motion media-query hook covered by #3287; tab-entry selected action reset covered by #3311; upstream #3279 feature-tip startup gating inspected: app-open prompt/seen-marking and shown telemetry are display-state/analytics sync, so no change. Continue checking global listeners, beforeunload, media-query, and active-tab repair. | | Terminal / PTY | `components/Terminal.tsx`, `components/terminal-pane/**`, `components/terminal/**` | Inventory complete, manual review pending | Mobile driver overlay collapse repair covered by #3170; floating workspace first-open bounds normalization covered by #3269; unused legacy terminal shell/shortcut/hook files deleted by #3271; terminal tab rename focus covered by #3314 without PTY protocol changes; terminal search focus covered by #3344; Activity terminal portal subscription covered by #3367. High-risk area: xterm lifecycle, scrollback, remote/mobile parity, focus, WebGL, resize. | | Renderer mobile page | `components/mobile/**` | Inventory complete, manual review in progress | Mobile pairing page stage repair covered by #3180. Continue checking QR generation, mobile-page IPC, and paired-device polling. | | Browser pane | `components/browser-pane/**` | Inventory complete, manual review in progress | Highest Effect density: 62 sites in `BrowserPane.tsx`. Browser ref mirrors covered by #3081; BrowserFind debounced-query state covered by #3275. Continue with driver sync, address bar derived state, remaining find state, webview lifetime. | @@ -64,7 +64,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Settings | `components/settings/**` | Inventory complete, manual review in progress | Repository hook local editor visibility covered by #3093; lazy section mounted-state bookkeeping covered by #3128; browser home/Kagi draft sync covered by #3136; general auto-save/Open In draft sync covered by #3138; Source Control AI instruction drafts covered by #3140; notification volume draft sync covered by #3142; repo Source Control AI override draft sync covered by #3144; Quick Commands add intent covered by #3146; Mobile QR polling count capture covered by #3152; integration preflight status derivation covered by #3154; SSH reset-relay dialog cleanup covered by #3157; diagnostics bundle cleanup ref mirror covered by #3176; font autocomplete option scroll covered by #3349; SSH passphrase prompt focus covered by #3351. Continue with draft mirrors while keeping SSH and cross-platform settings behavior intact. | | Automations | `components/automations/**` | Inventory complete, manual review in progress | Combobox open-focus Effects covered by #3100; run-history selection repair covered by #3104; external run-table selection repair covered by #3102; detail-page selection resets covered by #3106; create-from branch search reset covered by #3132. Continue with draft defaulting, polling, and completion detection. | | Issue, PR, task pages | `TaskPage.tsx`, `PullRequestPage.tsx`, `GitHubItemDialog.tsx`, `GitLabItemDialog.tsx`, Linear components | Inventory complete, manual review in progress | Linear estimate input sync covered by #3108; Linear sub-issue list sync covered by #3110; GitHub project date draft sync covered by #3112; GitHub reviewer-picker active index covered by #3114; GitHub project column state covered by #3116; GitHub Project visible-table cache covered by #3186; GitHub Project dialog state covered by #3214; GitLab comment draft reset covered by #3124; Linear title/description draft sync covered by #3130; GitHub assignee dirty-flag reset covered by #3255; GitHub reviewer-request local sync covered by #3259; GitHub link-copy feedback timers covered by #3285; GitLab task filter repair covered by #3292. Continue large-file scan with separate GitHub, GitLab, Linear, and generic review behavior. | -| Onboarding / feature wall | `components/onboarding/**`, `components/feature-wall/**` | Inventory complete, manual review in progress | Select portal root Effects covered by #3087; tour visited-marker Effects covered by #3095; persisted completion close reset covered by #3188; onboarding settings hydration covered by #3198; unused orchestration notifications page deleted by #3273; feature-wall skill installed-state notification Effects covered by #3281; session-depth open publish Effects covered by #3283; shared reduced-motion media-query hook covered by #3287; upstream #3279 added feature-tip modal and orchestration visual Effects to inspect. Continue with demo timers and telemetry while avoiding telemetry semantic changes. | +| Onboarding / feature wall | `components/onboarding/**`, `components/feature-wall/**` | Inventory complete, manual review in progress | Select portal root Effects covered by #3087; tour visited-marker Effects covered by #3095; persisted completion close reset covered by #3188; onboarding settings hydration covered by #3198; unused orchestration notifications page deleted by #3273; feature-wall skill installed-state notification Effects covered by #3281; session-depth open publish Effects covered by #3283; shared reduced-motion media-query hook covered by #3287; upstream #3279 feature-tip modal and orchestration visual Effects inspected: CLI command reveal timers and DOM arrow/demo animation stay classified as visual external sync. Continue with demo timers and telemetry while avoiding telemetry semantic changes. | | Status, dashboard, activity | `components/status-bar/**`, `components/dashboard/**`, `components/activity/**`, `components/pet/**` | Inventory complete, manual review in progress | Status-bar account menu close reset covered by #3051; pet overlay size clamp covered by #3118; Activity selected-thread repair covered by #3126; Workspace Space row-state repairs covered by #3168; Resource Manager Space scan-ready latch covered by #3200; pet reduced-motion subscription covered by #3287; status-bar account usage fetch covered by #3340; Space page open interaction covered by #3361. Check interval sharing, retained agent state, activity terminal portals. | | Mobile app routes | `mobile/app/**` | Inventory complete, manual review in progress | Pair-confirm route-code parsing covered by #3240; host home new-worktree route action covered by #3242; Accounts unused client ref covered by #3244; mobile home shared-client ref mirror covered by #3247; session route terminal/tab ref mirrors covered by #3250; tasks route client ref mirror covered by #3253; tasks route workspace agent selection covered by #3261. Continue with large `tasks.tsx` state/polling Effects, session route cleanup Effects, home route ref mirrors, and remote-client parity. | | Mobile shared source | `mobile/src/**` | Inventory complete, manual review in progress | TextInputModal and CustomKeyModal open-state resets covered by #3223; BottomDrawer mount bookkeeping covered by #3226; MobileBrowserPane address sync covered by #3228; MobileBrowserPane ref mirrors covered by #3230; useMobileDictation ref mirrors covered by #3234; NewWorktreeModal agent selection covered by #3238. Continue with transport client context plus NewWorktreeModal open/reset, SSH, agent-detection, and setup-hook fetch Effects. | From 0cc3bd77bca5cb7ee5af6724d8b161dad0ae1741 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 16:34:08 -0700 Subject: [PATCH 025/224] docs: update final memory leak comb after rebase (#3373) --- .../reference/memory-leak-audit-final-comb.md | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/reference/memory-leak-audit-final-comb.md b/docs/reference/memory-leak-audit-final-comb.md index 0608705f10a..6aa1dcc61a1 100644 --- a/docs/reference/memory-leak-audit-final-comb.md +++ b/docs/reference/memory-leak-audit-final-comb.md @@ -2,7 +2,7 @@ Started: 2026-05-29 PDT -Final tracker branch: `nwparker/mem-leak-final-comb-tracker`, based on `origin/main` at `869661cbba` after the final-comb fix PRs merged. +Current tracker branch: `nwparker/mem-leak-final-comb-current`, based on `origin/main` at `a99c376f3b` after rebasing over later mainline changes. Objective: run one more complete pass across the current Orca tree for missed listener, timer, observer, worker, socket, watcher, subscription, abort, and disposable leaks; open scoped PRs for confirmed stranglers; include risk level and merge low-risk PRs. @@ -12,9 +12,9 @@ Tracked code files, from `git ls-files`: | Bucket | Files | Status | | ----------------- | ----: | ------- | -| `src/renderer` | 1584 | Checked | -| `src/main` | 856 | Checked | -| `src/shared` | 245 | Checked | +| `src/renderer` | 1592 | Checked | +| `src/main` | 867 | Checked | +| `src/shared` | 246 | Checked | | `mobile/src` | 100 | Checked | | `tests/e2e` | 72 | Checked | | `src/relay` | 65 | Checked | @@ -24,7 +24,7 @@ Tracked code files, from `git ls-files`: | `other-code` | 5 | Checked | | `src/preload` | 6 | Checked | | `mobile/packages` | 5 | Checked | -| Total | 3052 | Checked | +| Total | 3072 | Checked | ## Scan Log @@ -40,6 +40,17 @@ Tracked code files, from `git ls-files`: - Abort controllers without `abort`: 1 test-only controller passed through the abortable API under test. - Watchers without close/unwatch: no production misses; remaining heuristic hits are provider type/comment references. - 2026-05-29: Re-ran React effect scan and spot-checked candidates. Remaining hits had cleanup returns (`clearTimeout`, `clearInterval`, `removeEventListener`, IPC unsubscribe) or were short one-shot UI focus/open delays. +- 2026-05-29: Rebased the final comb over current `origin/main` at `a99c376f3b` after additional mainline merges. +- 2026-05-29: Counted 3345 broad risk-pattern hits across `src`, `mobile`, `tests`, `config`, and `native` on the current tree. +- 2026-05-29: Re-ran heuristic buckets on the current tree: + - `addEventListener` without same-file `removeEventListener`: 37 remaining, same reviewed categories as the previous final-comb pass. + - `setInterval` without `clearInterval`: 1 remaining, a test comment string. + - `setTimeout` without same-file `clearTimeout`: 235 remaining. New changed-file production hits were intentional app relaunch/restart delays before process exit and a zero-delay floating-terminal focus handoff. Other changed timer candidates had explicit cleanup. + - Observers without `disconnect`: 0. + - Workers without `terminate`: 0. + - Abort controllers without `abort`: 1 test-only controller passed through the abortable API under test. + - Watchers without close/unwatch: no production misses; remaining heuristic hits are provider type/comment references. +- 2026-05-29: Focus-reviewed code changed since the previous tracker merge (`ca15aeb466..a99c376f3b`), including WSL runtime/account selection, daemon PTY checkpointing, main-window/rate-limit listeners, preload IPC subscriptions, feature-tip/settings/status-bar UI, feature-wall animation effects, FileExplorer reset effects, and renderer IPC subscriptions. No new confirmed leak needed a code PR. ## Findings @@ -51,6 +62,8 @@ Tracked code files, from `git ls-files`: No higher-risk findings remained after the final pass, so no second higher-risk mitigation pass was needed. +Current rebase verification found no additional confirmed findings beyond the already merged low-risk PRs above. + ## Validation - `pnpm vitest run --config config/vitest.config.ts src/main/browser/agent-browser-bridge.test.ts` @@ -59,3 +72,8 @@ No higher-risk findings remained after the final pass, so no second higher-risk - `pnpm vitest run --config config/vitest.config.ts src/main/ipc/developer-permissions.test.ts` - `pnpm exec oxlint src/main/browser/agent-browser-bridge.ts src/main/browser/agent-browser-bridge.test.ts src/main/ipc/notifications.ts src/main/ipc/notifications.test.ts src/main/computer/permissions.ts src/main/computer/permissions.test.ts src/main/ipc/developer-permissions.ts src/main/ipc/developer-permissions.test.ts` - `pnpm run typecheck:node` +- Current rebase pass: + - `git ls-files 'src/**/*.ts' 'src/**/*.tsx' 'mobile/**/*.ts' 'mobile/**/*.tsx' 'tests/**/*.ts' 'tests/**/*.tsx' 'config/**/*.js' 'config/**/*.mjs' 'native/**/*.ts'` + - broad `rg` risk-pattern count across `src`, `mobile`, `tests`, `config`, and `native` + - heuristic scans for unmatched listeners, intervals, timers, observers, workers, abort controllers, watchers, React effects, changed-file subscriptions, and changed-file timers + - manual changed-file review from `ca15aeb466` to `a99c376f3b` From 431b336f1ba115354ada2e2357660aa1df88d3e1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 16:39:10 -0700 Subject: [PATCH 026/224] Document project column preference audit (#3375) --- docs/reference/react-performance-audit.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md index 8a2529da1e1..a1d75a18326 100644 --- a/docs/reference/react-performance-audit.md +++ b/docs/reference/react-performance-audit.md @@ -49,7 +49,7 @@ Initial inventory: Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026 and upstream Orca CLI feature-tip changes #3279: 914 Effect hook call sites. -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, #3349, #3354, #3356, #3361, and #3365 each project to 913 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 912; open medium-risk PRs #3114, #3168, and #3259 each project to 911; open medium-risk PRs #3095, #3140, and #3230 each project to 910; open medium-risk PR #3326 projects to 905; open medium-risk PR #3079 projects to 904; open high-risk PRs #3157, #3212, #3253, #3351, and #3367 each project to 913; open high-risk PR #3267 projects to 912; open high-risk PR #3075 projects to 910; open high-risk PR #3250 projects to 909; and open high-risk PR #3081 projects to 906. These are not counted in the merged baseline until reviewed and merged. +Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, #3349, #3354, #3356, #3361, and #3365 each project to 913 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, #3287, and #3374 each project to 912; open medium-risk PRs #3114, #3168, and #3259 each project to 911; open medium-risk PRs #3095, #3140, and #3230 each project to 910; open medium-risk PR #3326 projects to 905; open medium-risk PR #3079 projects to 904; open high-risk PRs #3157, #3212, #3253, #3351, and #3367 each project to 913; open high-risk PR #3267 projects to 912; open high-risk PR #3075 projects to 910; open high-risk PR #3250 projects to 909; and open high-risk PR #3081 projects to 906. These are not counted in the merged baseline until reviewed and merged. | Area | Files / signal | Scan status | Notes | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -63,7 +63,7 @@ Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #31 | Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212; file-search input focus covered by #3330; file explorer inline create/rename focus covered by #3347. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | | Settings | `components/settings/**` | Inventory complete, manual review in progress | Repository hook local editor visibility covered by #3093; lazy section mounted-state bookkeeping covered by #3128; browser home/Kagi draft sync covered by #3136; general auto-save/Open In draft sync covered by #3138; Source Control AI instruction drafts covered by #3140; notification volume draft sync covered by #3142; repo Source Control AI override draft sync covered by #3144; Quick Commands add intent covered by #3146; Mobile QR polling count capture covered by #3152; integration preflight status derivation covered by #3154; SSH reset-relay dialog cleanup covered by #3157; diagnostics bundle cleanup ref mirror covered by #3176; font autocomplete option scroll covered by #3349; SSH passphrase prompt focus covered by #3351. Continue with draft mirrors while keeping SSH and cross-platform settings behavior intact. | | Automations | `components/automations/**` | Inventory complete, manual review in progress | Combobox open-focus Effects covered by #3100; run-history selection repair covered by #3104; external run-table selection repair covered by #3102; detail-page selection resets covered by #3106; create-from branch search reset covered by #3132. Continue with draft defaulting, polling, and completion detection. | -| Issue, PR, task pages | `TaskPage.tsx`, `PullRequestPage.tsx`, `GitHubItemDialog.tsx`, `GitLabItemDialog.tsx`, Linear components | Inventory complete, manual review in progress | Linear estimate input sync covered by #3108; Linear sub-issue list sync covered by #3110; GitHub project date draft sync covered by #3112; GitHub reviewer-picker active index covered by #3114; GitHub project column state covered by #3116; GitHub Project visible-table cache covered by #3186; GitHub Project dialog state covered by #3214; GitLab comment draft reset covered by #3124; Linear title/description draft sync covered by #3130; GitHub assignee dirty-flag reset covered by #3255; GitHub reviewer-request local sync covered by #3259; GitHub link-copy feedback timers covered by #3285; GitLab task filter repair covered by #3292. Continue large-file scan with separate GitHub, GitLab, Linear, and generic review behavior. | +| Issue, PR, task pages | `TaskPage.tsx`, `PullRequestPage.tsx`, `GitHubItemDialog.tsx`, `GitLabItemDialog.tsx`, Linear components | Inventory complete, manual review in progress | Linear estimate input sync covered by #3108; Linear sub-issue list sync covered by #3110; GitHub project date draft sync covered by #3112; GitHub reviewer-picker active index covered by #3114; GitHub project column state covered by #3116; GitHub Project visible-table cache covered by #3186; GitHub Project dialog state covered by #3214; GitHub Project column preferences covered by #3374; GitLab comment draft reset covered by #3124; Linear title/description draft sync covered by #3130; GitHub assignee dirty-flag reset covered by #3255; GitHub reviewer-request local sync covered by #3259; GitHub link-copy feedback timers covered by #3285; GitLab task filter repair covered by #3292. Continue large-file scan with separate GitHub, GitLab, Linear, and generic review behavior. | | Onboarding / feature wall | `components/onboarding/**`, `components/feature-wall/**` | Inventory complete, manual review in progress | Select portal root Effects covered by #3087; tour visited-marker Effects covered by #3095; persisted completion close reset covered by #3188; onboarding settings hydration covered by #3198; unused orchestration notifications page deleted by #3273; feature-wall skill installed-state notification Effects covered by #3281; session-depth open publish Effects covered by #3283; shared reduced-motion media-query hook covered by #3287; upstream #3279 feature-tip modal and orchestration visual Effects inspected: CLI command reveal timers and DOM arrow/demo animation stay classified as visual external sync. Continue with demo timers and telemetry while avoiding telemetry semantic changes. | | Status, dashboard, activity | `components/status-bar/**`, `components/dashboard/**`, `components/activity/**`, `components/pet/**` | Inventory complete, manual review in progress | Status-bar account menu close reset covered by #3051; pet overlay size clamp covered by #3118; Activity selected-thread repair covered by #3126; Workspace Space row-state repairs covered by #3168; Resource Manager Space scan-ready latch covered by #3200; pet reduced-motion subscription covered by #3287; status-bar account usage fetch covered by #3340; Space page open interaction covered by #3361. Check interval sharing, retained agent state, activity terminal portals. | | Mobile app routes | `mobile/app/**` | Inventory complete, manual review in progress | Pair-confirm route-code parsing covered by #3240; host home new-worktree route action covered by #3242; Accounts unused client ref covered by #3244; mobile home shared-client ref mirror covered by #3247; session route terminal/tab ref mirrors covered by #3250; tasks route client ref mirror covered by #3253; tasks route workspace agent selection covered by #3261. Continue with large `tasks.tsx` state/polling Effects, session route cleanup Effects, home route ref mirrors, and remote-client parity. | @@ -222,6 +222,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | PR EM | Space page open interaction | The Space page recorded workspace-cleanup feature usage from a passive mount Effect after the page rendered. | `WorkspaceSpacePage.tsx`, `ui.ts` covered by #3361 | Medium | | PR EN | Workspace board open interaction | The workspace board drawer recorded feature usage from a passive open-state Effect after the drawer rendered. | `SidebarHeader.tsx`, `WorkspaceKanbanDrawer.tsx` covered by #3365 | Medium | | PR EO | Activity terminal portal subscription | Activity terminal portal targets were mirrored from module state through a layout Effect and local state. | `activity-terminal-portal.ts` covered by #3367 | High | +| PR EP | GitHub Project column preferences | GitHub Project hidden-column and column-width preferences reloaded from localStorage in passive Effects after project/view scope changes. | `ProjectViewList.tsx` covered by #3374 | Medium | ## Merge Risk Scale @@ -372,6 +373,7 @@ These are candidate batches, not final conclusions. Each item needs code inspect | #3361 | `nwparker/react-perf-space-page-interaction` | Space page workspace-cleanup interaction records in `openSpacePage` | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx src/renderer/src/store/slices/ui.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm exec oxlint src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx src/renderer/src/store/slices/ui.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3365 | `nwparker/react-perf-workspace-board-open-interaction` | Workspace board feature interaction records in open handlers | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/sidebar/SidebarHeader.tsx src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx`; `pnpm exec oxlint src/renderer/src/components/sidebar/SidebarHeader.tsx src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | | #3367 | `nwparker/react-perf-activity-terminal-portals-store` | Activity terminal portal targets use `useSyncExternalStore` | High | Open | `pnpm exec oxfmt --write src/renderer/src/components/activity/activity-terminal-portal.ts`; `pnpm exec oxlint src/renderer/src/components/activity/activity-terminal-portal.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/activity/ActivityPrototypePage.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | +| #3374 | `nwparker/react-perf-project-column-preferences` | GitHub Project column preferences resolve before paint | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/github-project/ProjectViewList.tsx`; `pnpm exec oxlint src/renderer/src/components/github-project/ProjectViewList.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/github-project/project-row-filtering.test.ts src/renderer/src/components/github-project/group-sort.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 912. | ## Reproduction Commands From 30a09f3bd9728873592a89f66cfc2371a7eef2f7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 29 May 2026 19:39:52 -0400 Subject: [PATCH 027/224] Add mobile terminal shortcut bar customization (#3012) Co-authored-by: Orca --- .gitignore | 1 + mobile/app/_layout.tsx | 30 +- .../app/h/[hostId]/session/[worktreeId].tsx | 104 +- mobile/app/pair-confirm.tsx | 24 +- mobile/app/pair-scan.tsx | 4 +- mobile/app/pair.tsx | 87 + mobile/app/terminal-settings.tsx | 252 +- mobile/mock-homepage.html | 2285 +++++++----- mobile/mock-tasks.html | 3193 ++++++++++------- mobile/src/components/CustomKeyModal.tsx | 15 +- .../terminal/terminal-accessory-keys.test.ts | 35 +- .../src/terminal/terminal-accessory-keys.ts | 62 +- .../terminal-accessory-layout.test.ts | 142 + .../src/terminal/terminal-accessory-layout.ts | 137 + mobile/src/transport/pairing.test.ts | 21 + mobile/src/transport/pairing.ts | 29 +- src/cli/help.ts | 2 +- src/cli/runtime/client.ts | 2 +- src/cli/runtime/websocket-transport.test.ts | 5 +- src/cli/specs/environment.ts | 2 +- .../settings/RuntimeEnvironmentsPane.tsx | 2 +- src/renderer/src/web/WebConnect.tsx | 2 +- src/renderer/src/web/web-pairing.test.ts | 27 + src/renderer/src/web/web-pairing.ts | 9 +- src/shared/pairing.test.ts | 16 +- src/shared/pairing.ts | 34 +- src/shared/runtime-environment-store.ts | 2 +- src/shared/telemetry-events.test.ts | 2 + 28 files changed, 4103 insertions(+), 2423 deletions(-) create mode 100644 mobile/app/pair.tsx create mode 100644 mobile/src/terminal/terminal-accessory-layout.test.ts create mode 100644 mobile/src/terminal/terminal-accessory-layout.ts create mode 100644 mobile/src/transport/pairing.test.ts create mode 100644 src/renderer/src/web/web-pairing.test.ts diff --git a/.gitignore b/.gitignore index 1078c5105dd..de8a6eb8af3 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ docs/** !docs/reference/ !docs/reference/** !docs/STYLEGUIDE.md +!docs/mobile-terminal-shortcut-bar.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 188d52fe604..e6e091edf72 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -10,6 +10,7 @@ import { OrcaLogo } from '../src/components/OrcaLogo' import { RpcClientProvider } from '../src/transport/client-context' import { getNotificationNavigationPath } from '../src/notifications/notification-routing' import { loadHosts } from '../src/transport/host-store' +import { extractPairingCodeFromUrl } from '../src/transport/pairing' // Why: keeps the native splash screen visible until the React tree is mounted // and ready to render. Without this the user sees a blank white/black frame @@ -30,39 +31,23 @@ Notifications.setNotificationHandler({ }) }) -// Why: extract the path+payload that follows the orca://pair anchor so we -// can route it to the confirm screen. Accept either a hash payload -// (`orca://pair#`, the QR / shared form) or a query param -// (`orca://pair?code=<...>`, future-proof for share sheets that strip -// fragments). -function extractPairCode(url: string): string | null { - if (!url.startsWith('orca://pair')) return null - const hashIndex = url.indexOf('#') - if (hashIndex !== -1) { - return url.slice(hashIndex + 1) || null - } - const queryIndex = url.indexOf('?') - if (queryIndex !== -1) { - const params = new URLSearchParams(url.slice(queryIndex + 1)) - return params.get('code') - } - return null -} - export default function RootLayout() { const router = useRouter() const handledNotificationIdsRef = useRef>(new Set()) - // Why: route `orca://pair#` deep links to the confirm screen so + // Why: route `orca://pair?...` deep links to the confirm screen so // the same pairing flow runs whether the link arrived via QR scan, // paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL // covers cold-start (link tapped while app was closed); the listener // covers warm-start (link tapped while app is in memory). useEffect(() => { function handleUrl(url: string) { - const code = extractPairCode(url) + const code = extractPairingCodeFromUrl(url) if (code) { - router.push({ pathname: '/pair-confirm', params: { code } }) + // Why: Android camera launches can leave Expo Router's unmatched + // `orca://pair` route underneath this screen; replacing keeps cancel + // and edge-back from revealing the router error page. + router.replace({ pathname: '/pair-confirm', params: { code } }) } } @@ -169,6 +154,7 @@ export default function RootLayout() { }} /> + diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 5bd76d65e03..05a41c98775 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -18,13 +18,14 @@ import { type TextStyle } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useLocalSearchParams, useRouter } from 'expo-router' +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import AsyncStorage from '@react-native-async-storage/async-storage' import { AlertTriangle, ArrowUp, ChevronLeft, ChevronRight, + ChevronsRight, Eraser, Folder, File, @@ -59,7 +60,11 @@ import { type TerminalModes, type TerminalWebViewHandle } from '../../../../src/terminal/TerminalWebView' -import { TERMINAL_ACCESSORY_KEYS } from '../../../../src/terminal/terminal-accessory-keys' +import { + getDefaultTerminalAccessoryBuiltInIds, + getVisibleTerminalAccessoryKeys, + loadTerminalAccessoryLayout +} from '../../../../src/terminal/terminal-accessory-layout' import { getTerminalLiveSpecialKeyBytes, isTerminalLiveInputWithinByteLimit @@ -724,8 +729,15 @@ export default function SessionScreen() { const [leaveDrafts, setLeaveDrafts] = useState(null) const [renameTarget, setRenameTarget] = useState(null) const [customKeys, setCustomKeys] = useState([]) + const [visibleBuiltInIds, setVisibleBuiltInIds] = useState( + getDefaultTerminalAccessoryBuiltInIds + ) const [showCustomKeyModal, setShowCustomKeyModal] = useState(false) const [deleteKeyTarget, setDeleteKeyTarget] = useState(null) + const visibleBuiltInAccessoryKeys = useMemo( + () => getVisibleTerminalAccessoryKeys(visibleBuiltInIds), + [visibleBuiltInIds] + ) // Why: in Expo SDK 55 edge-to-edge mode the OS does NOT resize the window when // the IME opens — the keyboard draws on top of the app. We track the keyboard // height ourselves and translate the input/accessory area above the IME without @@ -1716,6 +1728,34 @@ export default function SessionScreen() { void loadCustomKeys().then(setCustomKeys) }, []) + useFocusEffect( + useCallback(() => { + let stale = false + void loadTerminalAccessoryLayout().then((layout) => { + if (!stale) setVisibleBuiltInIds(layout.visibleBuiltInIds) + }) + return () => { + stale = true + } + }, []) + ) + + useEffect(() => { + let mounted = true + const refresh = () => { + void loadTerminalAccessoryLayout().then((layout) => { + if (mounted) setVisibleBuiltInIds(layout.visibleBuiltInIds) + }) + } + const sub = AppState.addEventListener('change', (s: AppStateStatus) => { + if (s === 'active') refresh() + }) + return () => { + mounted = false + sub.remove() + } + }, []) + // Why: re-measure when non-keyboard layout-affecting state changes // (e.g. tab strip toggling visibility when the terminal count crosses // 0↔1 — without this, a freshly-created 2nd tab subscribes with a @@ -1815,6 +1855,11 @@ export default function SessionScreen() { [customKeys] ) + const handleManageShortcuts = useCallback(() => { + setShowCustomKeyModal(false) + router.push('/terminal-settings') + }, [router]) + useEffect(() => { clearTerminalCache() activeHandleRef.current = null @@ -3279,15 +3324,16 @@ export default function SessionScreen() { : 'Switch to live terminal input' } > - - Live - + {canPaste && ( )} - {TERMINAL_ACCESSORY_KEYS.map((key) => ( + {visibleBuiltInAccessoryKeys.map((key) => ( [ styles.accessoryKey, pressed && styles.accessoryKeyPressed, @@ -3383,12 +3429,9 @@ export default function SessionScreen() { onPress={focusLiveInput} accessibilityLabel="Focus live terminal input" > - - - Live - + - Keyboard input goes to terminal + Keyboard input directly goes to terminal setShowCustomKeyModal(false)} onKeysChanged={setCustomKeys} + onManageShortcuts={handleManageShortcuts} /> ([]) + const cancel = useCallback(() => { + router.replace('/') + }, [router]) + + useFocusEffect( + useCallback(() => { + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + cancel() + return true + }) + return () => subscription.remove() + }, [cancel]) + ) + useEffect(() => { if (!params.code) { setStatus('error') @@ -118,10 +132,6 @@ export default function PairConfirmScreen() { } } - function cancel() { - router.replace('/') - } - const containerPadding = { paddingTop: insets.top + spacing.sm } return ( diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index ca8a8673e7a..b64ea284a87 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -210,7 +210,7 @@ export default function PairScanScreen() { visible={pasteVisible} title="Paste pairing code" message="Copy the code shown under the QR on your computer." - placeholder="orca://pair#... or paste the code" + placeholder="orca://pair?code=... or paste the code" onSubmit={handlePasteSubmit} onCancel={() => setPasteVisible(false)} /> @@ -306,7 +306,7 @@ export default function PairScanScreen() { visible={pasteVisible} title="Paste pairing code" message="Copy the code shown under the QR on your computer." - placeholder="orca://pair#... or paste the code" + placeholder="orca://pair?code=... or paste the code" onSubmit={handlePasteSubmit} onCancel={() => setPasteVisible(false)} /> diff --git a/mobile/app/pair.tsx b/mobile/app/pair.tsx new file mode 100644 index 00000000000..6f3483763e1 --- /dev/null +++ b/mobile/app/pair.tsx @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useState } from 'react' +import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' +import { extractPairingCodeFromUrl } from '../src/transport/pairing' + +export default function PairRedirectScreen() { + const router = useRouter() + const params = useLocalSearchParams<{ code?: string }>() + const [missingCode, setMissingCode] = useState(false) + + const goHome = useCallback(() => { + router.replace('/') + }, [router]) + + useEffect(() => { + let disposed = false + + async function redirectToConfirm() { + const codeParam = Array.isArray(params.code) ? params.code[0] : params.code + if (codeParam) { + router.replace({ pathname: '/pair-confirm', params: { code: codeParam } }) + return + } + + const initialUrl = await Linking.getInitialURL().catch(() => null) + const code = initialUrl ? extractPairingCodeFromUrl(initialUrl) : null + if (disposed) { + return + } + if (code) { + router.replace({ pathname: '/pair-confirm', params: { code } }) + return + } + setMissingCode(true) + } + + void redirectToConfirm() + return () => { + disposed = true + } + }, [params.code, router]) + + return ( + + {missingCode ? ( + <> + Missing pairing code + + Back to home + + + ) : ( + + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + errorText: { + color: colors.statusRed, + fontSize: typography.bodySize, + lineHeight: 20, + marginBottom: spacing.xl, + textAlign: 'center' + }, + primaryButton: { + alignItems: 'center', + backgroundColor: colors.textPrimary, + borderRadius: radii.button, + paddingHorizontal: spacing.xl, + paddingVertical: spacing.sm + 2 + }, + primaryButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index abcf324a855..107ed53f925 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -1,14 +1,40 @@ -import { useEffect, useMemo, useState } from 'react' -import { View, Text, StyleSheet, Pressable, ScrollView } from 'react-native' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + AppState, + View, + Text, + StyleSheet, + Pressable, + ScrollView, + Switch, + type AppStateStatus +} from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useRouter } from 'expo-router' -import { ChevronLeft, ChevronRight, Smartphone } from 'lucide-react-native' -import { colors, spacing, typography } from '../src/theme/mobile-theme' +import { useFocusEffect, useRouter } from 'expo-router' +import { ChevronLeft, ChevronRight, Smartphone, X } from 'lucide-react-native' +import { + CustomKeyModal, + loadCustomKeys, + saveCustomKeys, + type CustomKey +} from '../src/components/CustomKeyModal' +import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' import { useAllHostClients } from '../src/transport/client-context' import type { RpcClient } from '../src/transport/rpc-client' import { PickerModal, type PickerOption } from '../src/components/PickerModal' +import { + TERMINAL_ACCESSORY_KEYS, + type TerminalAccessoryKey +} from '../src/terminal/terminal-accessory-keys' +import { + getDefaultTerminalAccessoryBuiltInIds, + loadTerminalAccessoryLayout, + resetTerminalAccessoryBuiltInIds, + saveTerminalAccessoryLayout, + setTerminalAccessoryBuiltInVisible +} from '../src/terminal/terminal-accessory-layout' type RestoreValue = 'indefinite' | '60s' | '5m' | '30m' @@ -74,6 +100,33 @@ function HostFitRow({ ) } +function ShortcutBarRow({ + shortcutKey, + visible, + onToggle +}: { + shortcutKey: TerminalAccessoryKey + visible: boolean + onToggle: (visible: boolean) => void +}): React.JSX.Element { + return ( + + + {shortcutKey.label} + + + {shortcutKey.accessibilityLabel ?? shortcutKey.label} + + + + ) +} + export default function TerminalSettingsScreen() { const router = useRouter() const insets = useSafeAreaInsets() @@ -84,6 +137,9 @@ export default function TerminalSettingsScreen() { const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) const hostClients = useAllHostClients(hostIds) + const [customKeys, setCustomKeys] = useState([]) + const [showCustomKeyModal, setShowCustomKeyModal] = useState(false) + // Why: per-host current value, lazily fetched. We keep state at the // screen level rather than per-row so the picker can render at root // level — embedding PickerModal inside a row clipped its BottomDrawer @@ -91,6 +147,79 @@ export default function TerminalSettingsScreen() { // drawer appear cut-off. const [hostMs, setHostMs] = useState>({}) const [pickerHostId, setPickerHostId] = useState(null) + const [visibleBuiltInIds, setVisibleBuiltInIds] = useState( + getDefaultTerminalAccessoryBuiltInIds + ) + const layoutWriteChainRef = useRef>(Promise.resolve()) + const layoutWriteSeqRef = useRef(0) + const pendingLayoutWritesRef = useRef(0) + + const persistLayout = useCallback((nextIds: string[]) => { + layoutWriteSeqRef.current += 1 + pendingLayoutWritesRef.current += 1 + layoutWriteChainRef.current = layoutWriteChainRef.current + .catch(() => {}) + .then(() => saveTerminalAccessoryLayout(nextIds)) + .catch(() => {}) + .finally(() => { + pendingLayoutWritesRef.current -= 1 + }) + }, []) + + const refreshShortcutLayout = useCallback(() => { + const refreshSeq = layoutWriteSeqRef.current + void loadTerminalAccessoryLayout().then((layout) => { + if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) return + setVisibleBuiltInIds(layout.visibleBuiltInIds) + }) + }, []) + + const refreshCustomKeys = useCallback(() => { + void loadCustomKeys().then(setCustomKeys) + }, []) + + const handleDeleteCustomKey = useCallback( + async (key: CustomKey) => { + const updated = customKeys.filter((k) => k.id !== key.id) + setCustomKeys(updated) + await saveCustomKeys(updated) + }, + [customKeys] + ) + + useFocusEffect( + useCallback(() => { + refreshShortcutLayout() + refreshCustomKeys() + }, [refreshShortcutLayout, refreshCustomKeys]) + ) + + useEffect(() => { + const sub = AppState.addEventListener('change', (s: AppStateStatus) => { + if (s === 'active') { + refreshShortcutLayout() + refreshCustomKeys() + } + }) + return () => sub.remove() + }, [refreshShortcutLayout, refreshCustomKeys]) + + const toggleBuiltInKey = useCallback( + (id: string, visible: boolean) => { + setVisibleBuiltInIds((current) => { + const next = setTerminalAccessoryBuiltInVisible(current, id, visible) + persistLayout(next) + return next + }) + }, + [persistLayout] + ) + + const resetBuiltInKeys = useCallback(() => { + const next = resetTerminalAccessoryBuiltInIds() + setVisibleBuiltInIds(next) + persistLayout(next) + }, [persistLayout]) useEffect(() => { let cancelled = false @@ -143,6 +272,7 @@ export default function TerminalSettingsScreen() { } const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null + const visibleBuiltInSet = useMemo(() => new Set(visibleBuiltInIds), [visibleBuiltInIds]) return ( @@ -186,6 +316,76 @@ export default function TerminalSettingsScreen() { })} )} + + SHORTCUT BAR + + {TERMINAL_ACCESSORY_KEYS.map((shortcutKey, idx) => ( + + {idx > 0 && } + toggleBuiltInKey(shortcutKey.id, visible)} + /> + + ))} + + [styles.row, pressed && styles.rowPressed]} + onPress={resetBuiltInKeys} + > + + Reset Defaults + Show every built-in shortcut key + + + + + CUSTOM SHORTCUTS + + {customKeys.length === 0 ? ( + + No custom shortcuts defined yet. + + ) : ( + customKeys.map((key, idx) => ( + + {idx > 0 && } + + + {key.label} + + + {key.label} + + {key.bytes.replace(/\r/g, ' ↵')} + + + [ + styles.deleteButton, + pressed && styles.deleteButtonPressed + ]} + onPress={() => handleDeleteCustomKey(key)} + > + + + + + )) + )} + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setShowCustomKeyModal(true)} + > + + Add Custom Shortcut… + Create key combo or text macro + + + + @@ -198,6 +398,14 @@ export default function TerminalSettingsScreen() { }} onClose={() => setPickerHostId(null)} /> + + setShowCustomKeyModal(false)} + onKeysChanged={(keys) => { + setCustomKeys(keys) + }} + /> ) } @@ -239,6 +447,9 @@ const styles = StyleSheet.create({ marginBottom: spacing.xs, paddingHorizontal: spacing.xs }, + groupTopGap: { + marginTop: spacing.xl + }, groupDescription: { fontSize: typography.bodySize - 1, color: colors.textSecondary, @@ -247,7 +458,7 @@ const styles = StyleSheet.create({ }, section: { backgroundColor: colors.bgPanel, - borderRadius: 12, + borderRadius: radii.card, overflow: 'hidden' }, sectionTopGap: { @@ -281,9 +492,38 @@ const styles = StyleSheet.create({ color: colors.textSecondary, marginTop: 2 }, + keycap: { + minWidth: 62, + alignItems: 'center', + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + keycapText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontFamily: typography.monoFamily + }, separator: { height: StyleSheet.hairlineWidth, backgroundColor: colors.borderSubtle, marginHorizontal: spacing.md + }, + emptyContainer: { + padding: spacing.md, + alignItems: 'center', + justifyContent: 'center' + }, + deleteButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(239, 68, 68, 0.1)' + }, + deleteButtonPressed: { + backgroundColor: 'rgba(239, 68, 68, 0.2)' } }) diff --git a/mobile/mock-homepage.html b/mobile/mock-homepage.html index b541dbb0a95..fe8301fd9bd 100644 --- a/mobile/mock-homepage.html +++ b/mobile/mock-homepage.html @@ -1,999 +1,1374 @@ - + - - - -Orca Mobile – Homepage Redesign - - - - -

- - - -
- - -
-
-
- - Orca -
-
- -
-
Welcome back
-
- -
-
- -
294
-
Agents
-
-
- -
1d 14h
-
Agent time
-
-
- -
277
-
PRs
-
-
- -
Desktops
-
-
-
- -
-
-
-
-
Host 1
-
-
Connected
-
-
- -
+ .empty-steps { + width: 100%; + padding: 0 24px 40px; + display: flex; + flex-direction: column; + gap: 0; + } + .empty-step { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 16px 0; + } + .empty-step + .empty-step { + border-top: 1px solid var(--border-subtle); + } + .step-num { + width: 28px; + height: 28px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + color: var(--text-secondary); + flex-shrink: 0; + margin-top: 1px; + } + .step-text { + flex: 1; + } + .step-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 3px; + } + .step-desc { + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; + } + + + +
+ + +
-
-
- -
-
-
Pair another desktop
-
Scan a QR code from Orca desktop
-
-
-
- -
- -
- - Settings -
-
- - -
-
-
- - Orca -
-
- - -
-
- -
- -
Welcome back
-
- - -
-
-
- -
-
294
-
Agents
-
-
-
- -
-
1d 14h
-
Agent time
-
-
-
- -
-
277
-
PRs
-
-
- -
Tasks
-
-
-
- -
-
-
Task inbox
-
- - - - - - GitHub, GitLab, Linear + +
+
+
+ + + + Orca
-
7
-
- -
-
-
-
-
3
-
Assigned
-
-
-
2
-
Review
-
-
-
2
-
Drafts
-
-
-
-
Best candidate: puts issues/PRs at the same hierarchy as desktops without overloading Quick Actions.
- -
Desktops
-
-
-
- -
+
+
Welcome back
-
-
Host 1
-
- 12 worktrees -
- 3 active + +
+
+ + + + +
294
+
Agents
+
+
+ + + + +
1d 14h
+
Agent time
+
+
+ + + + + + +
277
+
PRs
-
-
- - 7 + +
Desktops
+
+
+
+ + + + + +
+
+
+
+
Host 1
+
+
Connected
+
+
+ + + + + +
-
- + +
+
+ + + + + + +
+
+
Pair another desktop
+
+ Scan a QR code from Orca desktop +
+
-
-
-
- -
-
-
-
Work Laptop
-
- Disconnected -
- Last seen 2h ago -
-
-
- -
-
-
+
- -
Resume
-
-
- - -
-
-
fix-auth-middleware
-
- - orca  ·  feat/auth-v2 -
-
-
- -
-
- - -
Quick Actions
- - - -
Recent Activity
-
-
-
-
-
-
Assigned: Investigate remote task loading
-
GitHub · Host 1 · 8 min ago
-
-
-
-
-
-
Agent completed: fix login validation
-
Host 1 · 12 min ago
-
-
-
-
-
-
PR #284 merged: update auth middleware
-
Host 1 · 1h ago
-
-
-
-
-
-
Agent started: refactor payment flow
-
Host 1 · 2h ago
-
-
-
-
- -
-
- - -
-
-
- - Orca -
-
- -
-
- -
- -
Welcome to Orca
-
- -
-
-
- - - - - +
+ + + + Settings
-
Connect your desktop
-
- Pair with Orca on your computer to monitor worktrees, watch agents work, and manage terminals — all from your phone. -
-
-
-
How it works
-
-
1
-
-
Open Orca desktop
-
Go to Settings → Mobile and generate a pairing QR code.
+ +
+
+
+ + + + Orca +
+
+ +
-
-
2
-
-
Scan the code
-
Tap the button above to open the scanner. Point at the QR code on your screen.
+ +
+
Welcome back
+
+ + +
+
+
+ + + + +
+
294
+
Agents
+
+
+
+ + + + +
+
1d 14h
+
Agent time
+
+
+
+ + + + + + +
+
277
+
PRs
-
-
3
-
-
You're connected
-
Your desktop will appear here. Everything is encrypted end-to-end.
+ +
Tasks
+
+
+
+ + + + + + +
+
+
Task inbox
+
+ + + + + + GitHub, GitLab, Linear +
+
+
7
+
+ + + +
+
+
+
+
3
+
Assigned
+
+
+
2
+
Review
+
+
+
2
+
Drafts
+
+
+
+
+ Best candidate: puts issues/PRs at the same hierarchy as desktops without overloading Quick + Actions. +
+ + +
Desktops
+
+
+
+ + + + + +
+
+
+
Host 1
+
+ 12 worktrees +
+ 3 active +
+
+
+
+ + + + + + + 7 +
+
+ + + +
+
+
+ +
+
+ + + + + +
+
+
+
Work Laptop
+
+ Disconnected +
+ Last seen 2h ago +
+
+
+ + + +
+
+
+ + +
Resume
+
+
+ + + + + +
+
+
fix-auth-middleware
+
+ + orca  ·  feat/auth-v2 +
+
+
+ + + +
+
+ + +
Quick Actions
+ + + +
Recent Activity
+
+
+
+
+
+
Assigned: Investigate remote task loading
+
GitHub · Host 1 · 8 min ago
+
+
+
+
+
+
Agent completed: fix login validation
+
Host 1 · 12 min ago
+
+
+
+
+
+
PR #284 merged: update auth middleware
+
Host 1 · 1h ago
+
+
+
+
+
+
Agent started: refactor payment flow
+
Host 1 · 2h ago
+
+
+
+
+ +
+
+ + +
+
+
+ + + + Orca +
+
+ +
+
+ +
+
Welcome to Orca
+
+ +
+
+
+ + + + + + +
+
Connect your desktop
+
+ Pair with Orca on your computer to monitor worktrees, watch agents work, and manage + terminals — all from your phone. +
+ +
+ +
+
How it works
+
+
1
+
+
Open Orca desktop
+
Go to Settings → Mobile and generate a pairing QR code.
+
+
+
+
2
+
+
Scan the code
+
+ Tap the button above to open the scanner. Point at the QR code on your screen. +
+
+
+
+
3
+
+
You're connected
+
+ Your desktop will appear here. Everything is encrypted end-to-end. +
+
+
-
-
- - + + diff --git a/mobile/mock-tasks.html b/mobile/mock-tasks.html index 73ae68f3c4b..86eaa16ff5b 100644 --- a/mobile/mock-tasks.html +++ b/mobile/mock-tasks.html @@ -1,1030 +1,1420 @@ - + - - - -Orca Mobile - Tasks Mock - - - -
-
-
-
-
- -
- -

Tasks

+ + + + Orca Mobile - Tasks Mock + + + +
+
+
+
+
+ +
+ +

Tasks

+
+ +
- - + +
+ + +
+ + +
+ +
+

-
- - +
+
No GitHub tasks
+
+
+ +
- -
-

-
-
No GitHub tasks
-
-
+
+
+
- + - + function simulateLoad() { + state.loading = true + render() + window.setTimeout(() => { + state.loading = false + render() + }, 240) + } + + function showToast(message) { + toast.textContent = message + toast.classList.add('visible') + window.clearTimeout(showToast.timer) + showToast.timer = window.setTimeout(() => toast.classList.remove('visible'), 1500) + } + + function mutateSelectedStatus() { + const item = state.selectedItem + if (!item) return + if (state.provider === 'linear') return + if (item.state === 'closed') { + item.state = state.provider === 'gitlab' ? 'opened' : 'open' + item.status = 'Open' + } else { + item.state = 'closed' + item.status = 'Closed' + } + closeDrawer() + render() + showToast('Status updated') + } + + function createIssueFromForm() { + const title = state.createTitle.trim() + if (!title) return + const target = + state.provider === 'linear' + ? teams.find((team) => team.id === state.createTargetId) || teams[0] + : repos.find((repo) => repo.id === state.createTargetId) || repos[0] + const newItem = + state.provider === 'linear' + ? { + key: `lin-${Date.now()}`, + identifier: 'MOB-500', + title, + team: target.name, + status: 'Todo', + state: 'todo', + updatedAt: 'now', + labels: [] + } + : { + key: `${state.provider}-${Date.now()}`, + type: 'issue', + number: state.provider === 'github' ? 1291 : 75, + title, + repoId: target.id, + repoName: target.displayName, + status: 'Open', + state: state.provider === 'github' ? 'open' : 'opened', + updatedAt: 'now', + labels: [] + } + tasks[state.provider].unshift(newItem) + state.createTitle = '' + state.createBody = '' + closeDrawer() + render() + showToast('Issue created') + } + + function findCurrentItem(key) { + if (state.provider === 'gitlab' && state.gitlabView === 'todos') + return tasks.gitlabTodos.find((item) => item.key === key) + return tasks[state.provider].find((item) => item.key === key) + } + + function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } + + function escapeAttr(value) { + return escapeHtml(value).replace(/`/g, '`') + } + + document.addEventListener('click', (event) => { + const trigger = event.target.closest('[data-action]') + if (!trigger) return + const action = trigger.dataset.action + + if (action === 'open-provider') openDrawer('provider') + if (action === 'open-github-kind') openDrawer('github-kind') + if (action === 'open-github-preset') openDrawer('github-preset') + if (action === 'open-gitlab-view') openDrawer('gitlab-view') + if (action === 'open-gitlab-filter') openDrawer('gitlab-filter') + if (action === 'open-linear-filter') openDrawer('linear-filter') + if (action === 'close-drawer') closeDrawer() + if (action === 'open-create') { + state.createTitle = '' + state.createBody = '' + state.createTargetId = state.provider === 'linear' ? teams[0].id : repos[0].id + openDrawer('create') + } + if (action === 'open-create-target') openDrawer('create-target') + if (action === 'clear-search') { + state.query = '' + searchInput.value = '' + render() + } + if (action === 'refresh') { + simulateLoad() + showToast('Tasks refreshed') + } + if (action === 'toast') showToast(trigger.dataset.message || 'Action') + if (action === 'open-action') { + const item = findCurrentItem(trigger.dataset.key) + if (state.provider === 'gitlab' && state.gitlabView === 'todos') { + showToast('Would open GitLab todo URL') + return + } + if (item) openDrawer('action', item) + } + if (action === 'select-provider') setProvider(trigger.dataset.value) + if (action === 'select-github-kind') { + state.githubKind = trigger.dataset.value + const preset = state.githubKind === 'prs' ? prPresets[0] : issuePresets[0] + state.githubPreset = preset.value + state.query = preset.query + searchInput.value = state.query + closeDrawer() + render() + } + if (action === 'select-github-preset') { + const preset = [...issuePresets, ...prPresets].find( + (entry) => entry.value === trigger.dataset.value + ) + if (preset) { + state.githubPreset = preset.value + state.githubKind = + preset.value === 'issues' || preset.value === 'my-issues' ? 'issues' : 'prs' + state.query = preset.query + searchInput.value = state.query + } + closeDrawer() + render() + } + if (action === 'select-gitlab-view') { + state.gitlabView = trigger.dataset.value + closeDrawer() + render() + } + if (action === 'select-gitlab-filter') { + state.gitlabFilter = trigger.dataset.value + closeDrawer() + render() + } + if (action === 'select-linear-filter') { + state.linearFilter = trigger.dataset.value + closeDrawer() + render() + } + if (action === 'select-create-target') { + state.createTargetId = trigger.dataset.value + openDrawer('create') + } + if (action === 'select-workspace-target') { + closeDrawer() + showToast('Workspace created') + } + if (action === 'create-workspace') { + if (state.provider === 'linear' && repos.filter((repo) => !repo.unsupported).length > 1) + openDrawer('workspace-target', state.selectedItem) + else { + closeDrawer() + showToast('Workspace created') + } + } + if (action === 'open-browser') showToast('Would open task URL') + if (action === 'toggle-status') mutateSelectedStatus() + if (action === 'set-linear-status') { + const selected = linearStates.find((entry) => entry.id === trigger.dataset.value) + if (state.selectedItem && selected) { + state.selectedItem.state = selected.id + state.selectedItem.status = selected.name + } + closeDrawer() + render() + showToast('Linear status updated') + } + if (action === 'create-issue') createIssueFromForm() + }) + + document.addEventListener('input', (event) => { + if (event.target === searchInput) { + state.query = searchInput.value + render() + return + } + if (event.target.id === 'createTitle') { + state.createTitle = event.target.value + renderDrawer() + const input = document.getElementById('createTitle') + input.focus() + input.setSelectionRange(input.value.length, input.value.length) + } + if (event.target.id === 'createBody') { + state.createBody = event.target.value + } + }) + + render() + + diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index eb6b38d89ba..7931d4f9485 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -61,6 +61,7 @@ type Props = { visible: boolean onClose: () => void onKeysChanged: (keys: CustomKey[]) => void + onManageShortcuts?: () => void } export async function loadCustomKeys(): Promise { @@ -76,7 +77,7 @@ export async function saveCustomKeys(keys: CustomKey[]): Promise { await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys)) } -export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { +export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) { const [step, setStep] = useState('choose-type') const [shortcutKey, setShortcutKey] = useState('c') const [shortcutModifiers, setShortcutModifiers] = useState(['ctrl']) @@ -216,6 +217,18 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { Text Macro Send custom text command + {onManageShortcuts ? ( + <> + + [styles.row, pressed && styles.rowPressed]} + onPress={onManageShortcuts} + > + Manage Shortcuts + Show or hide default shortcut keys + + + ) : null} )} diff --git a/mobile/src/terminal/terminal-accessory-keys.test.ts b/mobile/src/terminal/terminal-accessory-keys.test.ts index 650f45dc4e1..13a91824819 100644 --- a/mobile/src/terminal/terminal-accessory-keys.test.ts +++ b/mobile/src/terminal/terminal-accessory-keys.test.ts @@ -4,20 +4,44 @@ import { buildTerminalShortcutKey, TERMINAL_ACCESSORY_KEYS } from './terminal-ac describe('TERMINAL_ACCESSORY_KEYS', () => { it('sends reverse-tab with a non-repeatable Shift+Tab key', () => { - const key = TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.label === 'Shift+Tab') + const key = TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.id === 'shiftTab') expect(key).toEqual({ + id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }) }) + it('includes a non-repeatable Enter default key', () => { + expect(TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.id === 'enter')).toEqual({ + id: 'enter', + label: 'Enter', + bytes: '\r', + accessibilityLabel: 'Enter' + }) + }) + + it('has unique non-empty built-in ids', () => { + const ids = TERMINAL_ACCESSORY_KEYS.map((key) => key.id) + + expect(ids.every((id) => id.length > 0)).toBe(true) + expect(new Set(ids).size).toBe(ids.length) + }) + it('keeps repeat behavior explicit for built-in terminal keys', () => { - const repeatableLabels = new Set(['⌫', 'Del', '↑', '↓', '←', '→']) + const repeatableIds = new Set([ + 'backspace', + 'delete', + 'arrowUp', + 'arrowDown', + 'arrowLeft', + 'arrowRight' + ]) for (const key of TERMINAL_ACCESSORY_KEYS) { - expect(key.repeatable === true).toBe(repeatableLabels.has(key.label)) + expect(key.repeatable === true).toBe(repeatableIds.has(key.id)) } }) @@ -45,6 +69,11 @@ describe('TERMINAL_ACCESSORY_KEYS', () => { bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }) + expect(buildTerminalShortcutKey({ key: 'enter', modifiers: [] })).toEqual({ + label: 'Enter', + bytes: '\r', + accessibilityLabel: 'Enter' + }) expect(buildTerminalShortcutKey({ key: 'arrowRight', modifiers: ['ctrl', 'shift'] })).toEqual({ label: 'Ctrl+Shift+→', bytes: '\x1b[1;6C', diff --git a/mobile/src/terminal/terminal-accessory-keys.ts b/mobile/src/terminal/terminal-accessory-keys.ts index 82ef9d83fc9..2923c25431b 100644 --- a/mobile/src/terminal/terminal-accessory-keys.ts +++ b/mobile/src/terminal/terminal-accessory-keys.ts @@ -1,4 +1,5 @@ export type TerminalAccessoryKey = { + id: string label: string bytes: string accessibilityLabel?: string @@ -199,25 +200,50 @@ export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [ })) export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [ - { label: 'Esc', bytes: '\x1b' }, - { label: 'Tab', bytes: '\t' }, + { id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' }, + { id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' }, + { id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' }, // Why: terminal apps recognize ESC [ Z as the reverse-tab sequence. - { label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }, - { label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true }, - { label: 'Del', bytes: '\x1b[3~', accessibilityLabel: 'Forward delete', repeatable: true }, - { label: '↑', bytes: '\x1b[A', repeatable: true }, - { label: '↓', bytes: '\x1b[B', repeatable: true }, - { label: '←', bytes: '\x1b[D', repeatable: true }, - { label: '→', bytes: '\x1b[C', repeatable: true }, - { label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' }, - { label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' }, - { label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' }, - { label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' }, - { label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' }, - { label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' }, - { label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' }, - { label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' }, - { label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' } + { id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }, + { id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true }, + { + id: 'delete', + label: 'Del', + bytes: '\x1b[3~', + accessibilityLabel: 'Forward delete', + repeatable: true + }, + { id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true }, + { + id: 'arrowDown', + label: '↓', + bytes: '\x1b[B', + accessibilityLabel: 'Arrow Down', + repeatable: true + }, + { + id: 'arrowLeft', + label: '←', + bytes: '\x1b[D', + accessibilityLabel: 'Arrow Left', + repeatable: true + }, + { + id: 'arrowRight', + label: '→', + bytes: '\x1b[C', + accessibilityLabel: 'Arrow Right', + repeatable: true + }, + { id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' }, + { id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' }, + { id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' }, + { id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' }, + { id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' }, + { id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' }, + { id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' }, + { id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' }, + { id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' } ] export function buildTerminalShortcutKey( diff --git a/mobile/src/terminal/terminal-accessory-layout.test.ts b/mobile/src/terminal/terminal-accessory-layout.test.ts new file mode 100644 index 00000000000..57e1d7f0343 --- /dev/null +++ b/mobile/src/terminal/terminal-accessory-layout.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, + createTerminalAccessoryLayoutPreference, + getDefaultTerminalAccessoryBuiltInIds, + getVisibleTerminalAccessoryKeys, + loadTerminalAccessoryLayout, + normalizeTerminalAccessoryLayoutPreference, + resetTerminalAccessoryBuiltInIds, + saveTerminalAccessoryLayout, + setTerminalAccessoryBuiltInVisible +} from './terminal-accessory-layout' + +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: asyncStorageMock +})) + +describe('terminal accessory layout', () => { + beforeEach(() => { + asyncStorageMock.getItem.mockReset() + asyncStorageMock.setItem.mockReset() + }) + + it('defaults include enter', () => { + expect(getDefaultTerminalAccessoryBuiltInIds()).toContain('enter') + expect(getVisibleTerminalAccessoryKeys(getDefaultTerminalAccessoryBuiltInIds())).toContainEqual( + expect.objectContaining({ id: 'enter', bytes: '\r' }) + ) + }) + + it('normalizes invalid storage to defaults', () => { + expect(normalizeTerminalAccessoryLayoutPreference(null).visibleBuiltInIds).toEqual( + getDefaultTerminalAccessoryBuiltInIds() + ) + expect( + normalizeTerminalAccessoryLayoutPreference({ + version: 1, + visibleBuiltInIds: ['escape'] + }).visibleBuiltInIds + ).toEqual(getDefaultTerminalAccessoryBuiltInIds()) + }) + + it('returns defaults for corrupt or unreadable storage', async () => { + asyncStorageMock.getItem.mockResolvedValueOnce('{') + await expect(loadTerminalAccessoryLayout()).resolves.toEqual( + createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds()) + ) + + asyncStorageMock.getItem.mockRejectedValueOnce(new Error('unreadable')) + await expect(loadTerminalAccessoryLayout()).resolves.toEqual( + createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds()) + ) + }) + + it('ignores removed ids and de-dupes visible ids', () => { + expect( + normalizeTerminalAccessoryLayoutPreference({ + version: 1, + visibleBuiltInIds: ['escape', 'removed', 'escape', 'tab'], + knownBuiltInIds: getDefaultTerminalAccessoryBuiltInIds() + }).visibleBuiltInIds + ).toEqual(['escape', 'tab']) + }) + + it('appends new defaults only when absent from known ids', () => { + const current = ['escape', 'tab', 'enter'] + + expect( + normalizeTerminalAccessoryLayoutPreference( + { + version: 1, + visibleBuiltInIds: ['escape'], + knownBuiltInIds: ['escape', 'tab'] + }, + current + ).visibleBuiltInIds + ).toEqual(['escape', 'enter']) + + expect( + normalizeTerminalAccessoryLayoutPreference( + { + version: 1, + visibleBuiltInIds: ['escape'], + knownBuiltInIds: current + }, + current + ).visibleBuiltInIds + ).toEqual(['escape']) + }) + + it('keeps hidden known defaults hidden, including an all-hidden layout', () => { + const current = ['escape', 'tab', 'enter'] + + expect( + normalizeTerminalAccessoryLayoutPreference( + { + version: 1, + visibleBuiltInIds: [], + knownBuiltInIds: current + }, + current + ).visibleBuiltInIds + ).toEqual([]) + }) + + it('toggle and reset helpers preserve built-in order', () => { + expect(setTerminalAccessoryBuiltInVisible(['tab'], 'escape', true, ['escape', 'tab'])).toEqual([ + 'escape', + 'tab' + ]) + expect( + setTerminalAccessoryBuiltInVisible(['escape', 'tab'], 'escape', false, ['escape', 'tab']) + ).toEqual(['tab']) + expect(resetTerminalAccessoryBuiltInIds()).toEqual(getDefaultTerminalAccessoryBuiltInIds()) + }) + + it('saves visible ids with current known built-in ids', async () => { + asyncStorageMock.setItem.mockResolvedValueOnce(undefined) + + await saveTerminalAccessoryLayout(['tab', 'tab', 'missing']) + + expect(asyncStorageMock.setItem).toHaveBeenCalledWith( + TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, + JSON.stringify(createTerminalAccessoryLayoutPreference(['tab'])) + ) + }) + + it('rejects write failures without mutating helper output', async () => { + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('nope')) + + await expect(saveTerminalAccessoryLayout(['escape'])).rejects.toThrow('nope') + expect(createTerminalAccessoryLayoutPreference(['escape']).visibleBuiltInIds).toEqual([ + 'escape' + ]) + }) +}) diff --git a/mobile/src/terminal/terminal-accessory-layout.ts b/mobile/src/terminal/terminal-accessory-layout.ts new file mode 100644 index 00000000000..2584e12fe3e --- /dev/null +++ b/mobile/src/terminal/terminal-accessory-layout.ts @@ -0,0 +1,137 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +import { TERMINAL_ACCESSORY_KEYS, type TerminalAccessoryKey } from './terminal-accessory-keys' + +export const TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY = 'orca:terminal-accessory-layout' + +export type TerminalAccessoryLayoutPreference = { + version: 1 + visibleBuiltInIds: string[] + knownBuiltInIds: string[] +} + +function builtInIds(): string[] { + return TERMINAL_ACCESSORY_KEYS.map((key) => key.id) +} + +function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreference { + return { + version: 1, + visibleBuiltInIds: [...ids], + knownBuiltInIds: [...ids] + } +} + +function stringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null + return value.every((item): item is string => typeof item === 'string') ? value : null +} + +function dedupeKnownIds(ids: string[], builtInSet: Set): string[] { + const seen = new Set() + const out: string[] = [] + for (const id of ids) { + if (!builtInSet.has(id) || seen.has(id)) continue + seen.add(id) + out.push(id) + } + return out +} + +export function getDefaultTerminalAccessoryBuiltInIds(): string[] { + return builtInIds() +} + +export function normalizeTerminalAccessoryLayoutPreference( + value: unknown, + currentBuiltInIds = builtInIds() +): TerminalAccessoryLayoutPreference { + const fallback = defaultPreference(currentBuiltInIds) + if (!value || typeof value !== 'object') return fallback + + const candidate = value as { + version?: unknown + visibleBuiltInIds?: unknown + knownBuiltInIds?: unknown + } + const visibleInput = stringArray(candidate.visibleBuiltInIds) + const knownInput = stringArray(candidate.knownBuiltInIds) + if (candidate.version !== 1 || !visibleInput || !knownInput) return fallback + + const builtInSet = new Set(currentBuiltInIds) + const knownInputSet = new Set(knownInput.filter((id) => builtInSet.has(id))) + const visibleBuiltInIds = dedupeKnownIds(visibleInput, builtInSet) + + for (const id of currentBuiltInIds) { + if (!knownInputSet.has(id) && !visibleBuiltInIds.includes(id)) { + visibleBuiltInIds.push(id) + } + } + + return { + version: 1, + visibleBuiltInIds, + knownBuiltInIds: [...currentBuiltInIds] + } +} + +export function createTerminalAccessoryLayoutPreference( + visibleBuiltInIds: string[], + currentBuiltInIds = builtInIds() +): TerminalAccessoryLayoutPreference { + return { + version: 1, + visibleBuiltInIds: dedupeKnownIds(visibleBuiltInIds, new Set(currentBuiltInIds)), + knownBuiltInIds: [...currentBuiltInIds] + } +} + +export function setTerminalAccessoryBuiltInVisible( + visibleBuiltInIds: string[], + id: string, + visible: boolean, + currentBuiltInIds = builtInIds() +): string[] { + const builtInSet = new Set(currentBuiltInIds) + if (!builtInSet.has(id)) { + return createTerminalAccessoryLayoutPreference(visibleBuiltInIds, currentBuiltInIds) + .visibleBuiltInIds + } + + const selected = new Set(dedupeKnownIds(visibleBuiltInIds, builtInSet)) + if (visible) { + selected.add(id) + } else { + selected.delete(id) + } + return currentBuiltInIds.filter((builtInId) => selected.has(builtInId)) +} + +export function resetTerminalAccessoryBuiltInIds(): string[] { + return builtInIds() +} + +export function getVisibleTerminalAccessoryKeys( + visibleBuiltInIds: string[] +): TerminalAccessoryKey[] { + const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key])) + return dedupeKnownIds(visibleBuiltInIds, new Set(byId.keys())).flatMap((id) => { + const key = byId.get(id) + return key ? [key] : [] + }) +} + +export async function loadTerminalAccessoryLayout(): Promise { + try { + const raw = await AsyncStorage.getItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY) + if (!raw) return defaultPreference() + return normalizeTerminalAccessoryLayoutPreference(JSON.parse(raw)) + } catch { + return defaultPreference() + } +} + +export async function saveTerminalAccessoryLayout(visibleBuiltInIds: string[]): Promise { + const preference = createTerminalAccessoryLayoutPreference(visibleBuiltInIds) + await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference)) +} diff --git a/mobile/src/transport/pairing.test.ts b/mobile/src/transport/pairing.test.ts new file mode 100644 index 00000000000..5b0a39bf037 --- /dev/null +++ b/mobile/src/transport/pairing.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { extractPairingCodeFromUrl } from './pairing' + +describe('pairing deep links', () => { + it('extracts the QR pairing code from the hash payload', () => { + expect(extractPairingCodeFromUrl('orca://pair#abc123')).toBe('abc123') + }) + + it('extracts the pairing code from a query param', () => { + expect(extractPairingCodeFromUrl('orca://pair?code=abc123')).toBe('abc123') + }) + + it('prefers the query pairing code when both query and hash are present', () => { + expect(extractPairingCodeFromUrl('orca://pair?code=query-code#hash-code')).toBe('query-code') + }) + + it('ignores empty and unrelated URLs', () => { + expect(extractPairingCodeFromUrl('orca://pair')).toBeNull() + expect(extractPairingCodeFromUrl('https://example.com/pair#abc123')).toBeNull() + }) +}) diff --git a/mobile/src/transport/pairing.ts b/mobile/src/transport/pairing.ts index 2b54f4e58db..a3a22b8bf6e 100644 --- a/mobile/src/transport/pairing.ts +++ b/mobile/src/transport/pairing.ts @@ -7,15 +7,36 @@ import { PairingOfferSchema, type PairingOffer } from './types' export function decodePairingUrl(url: string): PairingOffer | null { try { - const hashIndex = url.indexOf('#') - if (!url.startsWith('orca://pair') || hashIndex === -1) return null - return decodePairingBase64(url.slice(hashIndex + 1)) + const code = extractPairingCodeFromUrl(url) + if (!code) return null + return decodePairingBase64(code) } catch { return null } } -// Why: accept either an `orca://pair#` URL or the bare base64 +// Why: system camera apps hand us the raw custom-scheme URL. Keeping +// extraction here makes QR scan, paste, and external deep-link flows +// accept the same URL shapes. +export function extractPairingCodeFromUrl(url: string): string | null { + if (!url.startsWith('orca://pair')) return null + const queryIndex = url.indexOf('?') + if (queryIndex !== -1) { + const query = url.slice(queryIndex + 1).split('#')[0] ?? '' + const params = new URLSearchParams(query) + const code = params.get('code') + if (code) { + return code + } + } + const hashIndex = url.indexOf('#') + if (hashIndex !== -1) { + return url.slice(hashIndex + 1) || null + } + return null +} + +// Why: accept either an `orca://pair?...` URL or the bare base64 // string so the paste-pair flow can take whichever the user actually // copied from desktop. export function parsePairingCode(input: string): PairingOffer | null { diff --git a/src/cli/help.ts b/src/cli/help.ts index 1e1202dc345..50de2d06059 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -209,7 +209,7 @@ Wait Options: Output Options: --json Emit machine-readable JSON instead of human text - --pairing-code Connect to a remote Orca runtime using an orca://pair#... code + --pairing-code Connect to a remote Orca runtime using an orca://pair?... code --environment Connect using a saved environment id or name --help Show this help message diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index aaff8ec224c..aa930341eee 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -209,7 +209,7 @@ function resolveRemotePairing( if (!pairing) { throw new RuntimeClientError( 'invalid_argument', - 'Invalid remote pairing code. Expected an orca://pair#... URL or bare pairing payload.' + 'Invalid remote pairing code. Expected an orca://pair?... URL or bare pairing payload.' ) } return pairing diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 38ccf06f956..3ad50666be8 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -66,7 +66,10 @@ describe('CLI remote WebSocket transport', () => { deviceToken: runtime.deviceToken, publicKeyB64: runtime.publicKeyB64 } - const barePayload = encodePairingOffer(offer).split('#')[1]! + const pairingUrl = encodePairingOffer(offer) + const barePayload = new URLSearchParams(pairingUrl.slice(pairingUrl.indexOf('?') + 1)).get( + 'code' + )! const client = new RuntimeClient('/tmp/unused', 5_000, barePayload) const status = await client.getCliStatus() diff --git a/src/cli/specs/environment.ts b/src/cli/specs/environment.ts index 6bfe1940b65..2b10baff97e 100644 --- a/src/cli/specs/environment.ts +++ b/src/cli/specs/environment.ts @@ -7,7 +7,7 @@ export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [ summary: 'Save a remote Orca runtime environment from a pairing code', usage: 'orca environment add --name --pairing-code [--json]', allowedFlags: [...GLOBAL_FLAGS, 'name'], - examples: ['orca environment add --name work-laptop --pairing-code orca://pair#...'] + examples: ['orca environment add --name work-laptop --pairing-code orca://pair?code=...'] }, { path: ['environment', 'list'], diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index 12494834c95..d90bba6bb8f 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -316,7 +316,7 @@ export function RuntimeEnvironmentsPane({ aria-describedby="runtime-server-pairing-code-help" value={pairingCode} onChange={(event) => setPairingCode(event.target.value)} - placeholder="orca://pair#..." + placeholder="orca://pair?code=..." className="h-8 min-w-0 font-mono text-xs" />

diff --git a/src/renderer/src/web/WebConnect.tsx b/src/renderer/src/web/WebConnect.tsx index a7b9c56a969..984c7126e23 100644 --- a/src/renderer/src/web/WebConnect.tsx +++ b/src/renderer/src/web/WebConnect.tsx @@ -100,7 +100,7 @@ export default function WebConnect({ id="web-runtime-pairing-code" value={pairingCode} onChange={(event) => setPairingCode(event.target.value)} - placeholder="orca://pair#..." + placeholder="orca://pair?code=..." autoComplete="off" spellCheck={false} /> diff --git a/src/renderer/src/web/web-pairing.test.ts b/src/renderer/src/web/web-pairing.test.ts new file mode 100644 index 00000000000..9c62650a1a7 --- /dev/null +++ b/src/renderer/src/web/web-pairing.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { parseWebPairingInput, type WebPairingOffer } from './web-pairing' + +describe('web pairing input', () => { + const offer: WebPairingOffer = { + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: 'public-key' + } + + function encodeOffer() { + return Buffer.from(JSON.stringify(offer), 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + } + + it('parses query-form pairing URLs', () => { + expect(parseWebPairingInput(`orca://pair?code=${encodeOffer()}`)).toEqual(offer) + }) + + it('still parses legacy hash-form pairing URLs', () => { + expect(parseWebPairingInput(`orca://pair#${encodeOffer()}`)).toEqual(offer) + }) +}) diff --git a/src/renderer/src/web/web-pairing.ts b/src/renderer/src/web/web-pairing.ts index cc2fc747ab5..6677995535d 100644 --- a/src/renderer/src/web/web-pairing.ts +++ b/src/renderer/src/web/web-pairing.ts @@ -15,6 +15,13 @@ export function parseWebPairingInput(input: string): WebPairingOffer | null { try { if (trimmed.startsWith('orca://pair')) { + const queryIndex = trimmed.indexOf('?') + if (queryIndex !== -1) { + const query = trimmed.slice(queryIndex + 1).split('#')[0] ?? '' + const params = new URLSearchParams(query) + const code = params.get('code') + return code ? decodePairingPayload(code) : null + } const hashIndex = trimmed.indexOf('#') if (hashIndex === -1) { return null @@ -88,7 +95,7 @@ function decodePairingPayload(base64url: string): WebPairingOffer | null { function base64UrlToBytes(value: string): Uint8Array { const base64 = value.replace(/-/g, '+').replace(/_/g, '/') const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=') - const binary = window.atob(padded) + const binary = globalThis.atob(padded) const bytes = new Uint8Array(binary.length) for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index) diff --git a/src/shared/pairing.test.ts b/src/shared/pairing.test.ts index cf469f6ed4b..3c971e61da1 100644 --- a/src/shared/pairing.test.ts +++ b/src/shared/pairing.test.ts @@ -16,7 +16,7 @@ describe('pairing offer', () => { it('encode then decode round-trips correctly', () => { const url = encodePairingOffer(offer) - expect(url).toMatch(/^orca:\/\/pair#/) + expect(url).toMatch(/^orca:\/\/pair\?code=/) const decoded = decodePairingOffer(url) expect(decoded).toEqual(offer) @@ -24,18 +24,24 @@ describe('pairing offer', () => { it('encoded URL uses base64url (no +, /, or = characters)', () => { const url = encodePairingOffer(offer) - const fragment = url.split('#')[1]! - expect(fragment).not.toMatch(/[+/=]/) + const code = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')! + expect(code).not.toMatch(/[+/=]/) }) it('rejects URLs with wrong scheme', () => { expect(() => decodePairingOffer('https://example.com#abc')).toThrow('Invalid pairing URL') }) - it('rejects URLs without fragment', () => { + it('rejects URLs without a pairing code', () => { expect(() => decodePairingOffer('orca://pair')).toThrow('Invalid pairing URL') }) + it('decodes legacy hash URLs', () => { + const url = encodePairingOffer(offer) + const code = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')! + expect(decodePairingOffer(`orca://pair#${code}`)).toEqual(offer) + }) + it('rejects payloads with missing fields', () => { const partial = { v: 2, endpoint: 'ws://host:1234' } const base64 = Buffer.from(JSON.stringify(partial)).toString('base64') @@ -70,7 +76,7 @@ describe('parsePairingCode', () => { it('parses a bare base64url payload (without scheme prefix)', () => { const url = encodePairingOffer(offer) - const base64url = url.split('#')[1]! + const base64url = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')! expect(parsePairingCode(base64url)).toEqual(offer) }) diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts index 4e9b5beba80..220525c7665 100644 --- a/src/shared/pairing.ts +++ b/src/shared/pairing.ts @@ -20,18 +20,40 @@ export function encodePairingOffer(offer: PairingOffer): string { .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') - return `orca://pair#${base64url}` + // Why: Android camera intents and Expo Router preserve query params more + // reliably than URL fragments when launching a custom-scheme app. + return `orca://pair?code=${base64url}` } export function decodePairingOffer(url: string): PairingOffer { - const hashIndex = url.indexOf('#') - if (!url.startsWith('orca://pair') || hashIndex === -1) { - throw new Error('Invalid pairing URL: must start with orca://pair#') + const code = extractPairingCodeFromUrl(url) + if (!code) { + throw new Error('Invalid pairing URL: must start with orca://pair and include a pairing code') } - return decodePairingBase64(url.slice(hashIndex + 1)) + return decodePairingBase64(code) } -// Why: accept either an `orca://pair#` URL or the bare base64 +function extractPairingCodeFromUrl(url: string): string | null { + if (!url.startsWith('orca://pair')) { + return null + } + const queryIndex = url.indexOf('?') + if (queryIndex !== -1) { + const query = url.slice(queryIndex + 1).split('#')[0] ?? '' + const params = new URLSearchParams(query) + const code = params.get('code') + if (code) { + return code + } + } + const hashIndex = url.indexOf('#') + if (hashIndex !== -1) { + return url.slice(hashIndex + 1) || null + } + return null +} + +// Why: accept either an `orca://pair?...` URL or the bare base64 // string so the mobile paste-pair flow can take whichever the user // actually copied from desktop. export function parsePairingCode(input: string): PairingOffer | null { diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index 2682ffb74dd..465d8af82b5 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -42,7 +42,7 @@ export function addEnvironmentFromPairingCode( if (!offer) { throw new RuntimeEnvironmentStoreError( 'invalid_argument', - 'Invalid pairing code. Expected an orca://pair#... URL or bare pairing payload.' + 'Invalid pairing code. Expected an orca://pair?... URL or bare pairing payload.' ) } const store = readEnvironmentStore(userDataPath) diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index 90924dd5033..4363144af8c 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: telemetry schema tests keep related event + invariants together so cross-event payload rules stay easy to audit. */ // Schema round-trip coverage for the event map. Fail-closed invariants that // must hold: agent_error is enum-only (error_message / error_stack rejected // by `.strict()`), unknown enum values fail, and any well-formed payload From 5ac2ceb672eb701d771015bde5b14ab251a5c526 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 16:48:32 -0700 Subject: [PATCH 028/224] Fix sidebar scroll lock during workspace delete (#3376) Fix sidebar scroll lock during workspace delete --- .../sidebar/WorktreeContextMenu.test.ts | 14 ++++++++++++- .../sidebar/WorktreeContextMenu.tsx | 21 ++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts index 28dde5ccb32..f7309818a9b 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts @@ -5,7 +5,8 @@ import { shouldUseNativeContextMenu, shouldIgnoreNestedWorktreeContextMenuScope, shouldRemoveFolderProjectFromContextMenu, - shouldSuppressContextMenuFollowUpClick + shouldSuppressContextMenuFollowUpClick, + shouldContinueDeleteSiblingPositionRestore } from './WorktreeContextMenu' describe('shouldUseNativeContextMenu', () => { @@ -94,6 +95,17 @@ describe('shouldSuppressContextMenuFollowUpClick', () => { }) }) +describe('shouldContinueDeleteSiblingPositionRestore', () => { + it('stops once the delete row position has settled even when the row remains mounted', () => { + expect( + shouldContinueDeleteSiblingPositionRestore({ + attempts: 6, + stableFrames: 6 + }) + ).toBe(false) + }) +}) + describe('hasSleepableWorkspaceActivity', () => { it('treats preserved empty PTY arrays as slept, not live', () => { expect( diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 3fc76a00907..25c968550f7 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -57,6 +57,8 @@ const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' const WORKTREE_CONTEXT_MENU_SCOPE_ATTR = 'data-worktree-context-menu-scope' const WORKTREE_NATIVE_CONTEXT_MENU_ATTR = 'data-worktree-native-context-menu' const CONTEXT_MENU_CLICK_SUPPRESSION_MS = 500 +const DELETE_POSITION_RESTORE_MAX_FRAMES = 180 +const DELETE_POSITION_RESTORE_STABLE_FRAMES = 6 function shouldUseNativeContextMenu(target: EventTarget | null): boolean { const maybeElement = target as { @@ -127,6 +129,18 @@ function findSidebarVirtualRowByKey(sidebar: Element, rowKey: string): HTMLEleme ) } +export function shouldContinueDeleteSiblingPositionRestore(args: { + attempts: number + stableFrames: number +}): boolean { + // Why: slow deletes leave the target row mounted; after initial focus/remount + // settling, the restore loop must stop so user scrolling wins. + return ( + args.attempts < DELETE_POSITION_RESTORE_MAX_FRAMES && + args.stableFrames < DELETE_POSITION_RESTORE_STABLE_FRAMES + ) +} + function preserveDeleteSiblingPosition(scope: HTMLElement | null): () => void { const sidebar = scope?.closest('[data-worktree-sidebar]') const row = scope?.closest('[data-worktree-virtual-row]') @@ -173,7 +187,12 @@ function preserveDeleteSiblingPosition(scope: HTMLElement | null): () => void { stableFrames = 0 } attempts += 1 - if (attempts < 180 && (currentTarget || stableFrames < 6)) { + if ( + shouldContinueDeleteSiblingPositionRestore({ + attempts, + stableFrames + }) + ) { window.requestAnimationFrame(restore) } } From 1be26f657c042f337144f0512cffd0a1c6fb5b1d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 23:49:11 +0000 Subject: [PATCH 029/224] release: v1.4.36-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index da2a7c5e2b0..33f75a8a2cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.36-rc.0", + "version": "1.4.36-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 22bc5fdc07bd9c91c9e603ae93bb6df4a7790d7f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 16:50:44 -0700 Subject: [PATCH 030/224] Remove tracked React perf audit ledger (#3379) --- docs/reference/react-performance-audit.md | 387 ---------------------- 1 file changed, 387 deletions(-) delete mode 100644 docs/reference/react-performance-audit.md diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md deleted file mode 100644 index a1d75a18326..00000000000 --- a/docs/reference/react-performance-audit.md +++ /dev/null @@ -1,387 +0,0 @@ -# React Performance Audit - -Status: in progress -Started: 2026-05-29 -Base commit: `b7fe967780` (`origin/main`, release v1.4.35) - -## Goal - -Scan the full React surface for `$perf` and `$react-useeffect` issues, keep coverage evidence here, and land the fixes as many small PRs with explicit merge-risk notes. - -This document is the audit ledger. A section is not considered fully scanned until it has: - -1. File inventory checked from the current worktree. -2. `useEffect` / `useLayoutEffect` / `useInsertionEffect` sites classified. -3. Perf-sensitive patterns checked: timers, subscriptions, observers, `JSON.stringify`, storage writes, polling, broad store subscriptions, large list rendering, and render-time derived state. -4. Each suspicious site dispositioned as "no change", "needs PR", or "covered by PR". -5. Merge risk recorded for every PR candidate. - -## Scope - -React hook call scan uses all repo `*.ts` and `*.tsx` files, then narrows to actual AST hook calls. The React Effect surface found on 2026-05-29 is: - -- `src/renderer/src/**` -- `mobile/app/**` -- `mobile/src/**` -- `mobile/packages/expo-two-way-audio/src/hooks.ts` - -Comment-only mentions outside those paths were ignored. - -Initial inventory: - -| Metric | Count | -| --------------------------------------------------- | ----: | -| Repo `*.ts` / `*.tsx` files | 3,011 | -| Files with real Effect hook calls | 285 | -| Effect hook call sites | 970 | -| `useLayoutEffect` / `useInsertionEffect` call sites | 44 | -| Empty dependency arrays | 111 | -| Effects with cleanup returns | 472 | -| Effects with subscription/listener/observer shape | 170 | -| Effects with timer or animation-frame shape | 128 | -| Effects with `JSON.stringify` shape | 2 | -| Set-state-shaped Effects needing manual review | 270 | -| `useMemo` call sites in React scope | 633 | -| `useCallback` call sites in React scope | 1,415 | -| `useSyncExternalStore` call sites in React scope | 8 | - -## Coverage Ledger - -Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, #3300, and #3330, plus upstream tab-entry changes #3011 and #3026 and upstream Orca CLI feature-tip changes #3279: 914 Effect hook call sites. - -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, #3322, #3328, #3333, #3338, #3340, #3344, #3347, #3349, #3354, #3356, #3361, and #3365 each project to 913 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, #3287, and #3374 each project to 912; open medium-risk PRs #3114, #3168, and #3259 each project to 911; open medium-risk PRs #3095, #3140, and #3230 each project to 910; open medium-risk PR #3326 projects to 905; open medium-risk PR #3079 projects to 904; open high-risk PRs #3157, #3212, #3253, #3351, and #3367 each project to 913; open high-risk PR #3267 projects to 912; open high-risk PR #3075 projects to 910; open high-risk PR #3250 projects to 909; and open high-risk PR #3081 projects to 906. These are not counted in the merged baseline until reviewed and merged. - -| Area | Files / signal | Scan status | Notes | -| ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| Renderer app shell | `src/renderer/src/App.tsx`, root components | Inventory complete, manual review in progress | Confirmation dialog active request mirror covered by #3091; onboarding settings detour reset covered by #3122; quick workspace agent selection repair covered by #3148; Cmd+J selection repair covered by #3164; smart workspace command highlight covered by #3192; App lazy-modal mount bookkeeping covered by #3220; shared reduced-motion media-query hook covered by #3287; tab-entry selected action reset covered by #3311; upstream #3279 feature-tip startup gating inspected: app-open prompt/seen-marking and shown telemetry are display-state/analytics sync, so no change. Continue checking global listeners, beforeunload, media-query, and active-tab repair. | -| Terminal / PTY | `components/Terminal.tsx`, `components/terminal-pane/**`, `components/terminal/**` | Inventory complete, manual review pending | Mobile driver overlay collapse repair covered by #3170; floating workspace first-open bounds normalization covered by #3269; unused legacy terminal shell/shortcut/hook files deleted by #3271; terminal tab rename focus covered by #3314 without PTY protocol changes; terminal search focus covered by #3344; Activity terminal portal subscription covered by #3367. High-risk area: xterm lifecycle, scrollback, remote/mobile parity, focus, WebGL, resize. | -| Renderer mobile page | `components/mobile/**` | Inventory complete, manual review in progress | Mobile pairing page stage repair covered by #3180. Continue checking QR generation, mobile-page IPC, and paired-device polling. | -| Browser pane | `components/browser-pane/**` | Inventory complete, manual review in progress | Highest Effect density: 62 sites in `BrowserPane.tsx`. Browser ref mirrors covered by #3081; BrowserFind debounced-query state covered by #3275. Continue with driver sync, address bar derived state, remaining find state, webview lifetime. | -| Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322; rich markdown menu selection repair covered by #3326; editor header rename focus covered by #3328; diff-comment popover focus covered by #3333; markdown annotation composer focus covered by #3338; diff-comment card resize covered by #3354; contextual-copy toast cleanup covered by #3356. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | -| Sidebar / worktrees | `components/sidebar/**` | Inventory complete, manual review in progress | Orca hook trust checkbox reset covered by #3089; delete dialog one-shot checkbox reset covered by #3134; Add Repo clone destination auto-fill covered by #3150; sidebar live-width mirror covered by #3160; worktree selection pruning covered by #3166; SSH reconnect prompts covered by #3267; nested repo indeterminate checkbox covered by #3294; workspace title rename focus covered by #3296; workspace title editing notification covered by #3298; worktree metadata notes resize covered by #3300; workspace board open interaction covered by #3365. Continue with drag/drop global listeners and kanban pointer flows. | -| Workspace cleanup | `components/workspace-cleanup/**` | Inventory complete, manual review in progress | Active cleanup-view repair covered by #3195. Continue checking destructive removal, repo filtering, scan refresh, and SSH/disconnected blockers. | -| Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212; file-search input focus covered by #3330; file explorer inline create/rename focus covered by #3347. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | -| Settings | `components/settings/**` | Inventory complete, manual review in progress | Repository hook local editor visibility covered by #3093; lazy section mounted-state bookkeeping covered by #3128; browser home/Kagi draft sync covered by #3136; general auto-save/Open In draft sync covered by #3138; Source Control AI instruction drafts covered by #3140; notification volume draft sync covered by #3142; repo Source Control AI override draft sync covered by #3144; Quick Commands add intent covered by #3146; Mobile QR polling count capture covered by #3152; integration preflight status derivation covered by #3154; SSH reset-relay dialog cleanup covered by #3157; diagnostics bundle cleanup ref mirror covered by #3176; font autocomplete option scroll covered by #3349; SSH passphrase prompt focus covered by #3351. Continue with draft mirrors while keeping SSH and cross-platform settings behavior intact. | -| Automations | `components/automations/**` | Inventory complete, manual review in progress | Combobox open-focus Effects covered by #3100; run-history selection repair covered by #3104; external run-table selection repair covered by #3102; detail-page selection resets covered by #3106; create-from branch search reset covered by #3132. Continue with draft defaulting, polling, and completion detection. | -| Issue, PR, task pages | `TaskPage.tsx`, `PullRequestPage.tsx`, `GitHubItemDialog.tsx`, `GitLabItemDialog.tsx`, Linear components | Inventory complete, manual review in progress | Linear estimate input sync covered by #3108; Linear sub-issue list sync covered by #3110; GitHub project date draft sync covered by #3112; GitHub reviewer-picker active index covered by #3114; GitHub project column state covered by #3116; GitHub Project visible-table cache covered by #3186; GitHub Project dialog state covered by #3214; GitHub Project column preferences covered by #3374; GitLab comment draft reset covered by #3124; Linear title/description draft sync covered by #3130; GitHub assignee dirty-flag reset covered by #3255; GitHub reviewer-request local sync covered by #3259; GitHub link-copy feedback timers covered by #3285; GitLab task filter repair covered by #3292. Continue large-file scan with separate GitHub, GitLab, Linear, and generic review behavior. | -| Onboarding / feature wall | `components/onboarding/**`, `components/feature-wall/**` | Inventory complete, manual review in progress | Select portal root Effects covered by #3087; tour visited-marker Effects covered by #3095; persisted completion close reset covered by #3188; onboarding settings hydration covered by #3198; unused orchestration notifications page deleted by #3273; feature-wall skill installed-state notification Effects covered by #3281; session-depth open publish Effects covered by #3283; shared reduced-motion media-query hook covered by #3287; upstream #3279 feature-tip modal and orchestration visual Effects inspected: CLI command reveal timers and DOM arrow/demo animation stay classified as visual external sync. Continue with demo timers and telemetry while avoiding telemetry semantic changes. | -| Status, dashboard, activity | `components/status-bar/**`, `components/dashboard/**`, `components/activity/**`, `components/pet/**` | Inventory complete, manual review in progress | Status-bar account menu close reset covered by #3051; pet overlay size clamp covered by #3118; Activity selected-thread repair covered by #3126; Workspace Space row-state repairs covered by #3168; Resource Manager Space scan-ready latch covered by #3200; pet reduced-motion subscription covered by #3287; status-bar account usage fetch covered by #3340; Space page open interaction covered by #3361. Check interval sharing, retained agent state, activity terminal portals. | -| Mobile app routes | `mobile/app/**` | Inventory complete, manual review in progress | Pair-confirm route-code parsing covered by #3240; host home new-worktree route action covered by #3242; Accounts unused client ref covered by #3244; mobile home shared-client ref mirror covered by #3247; session route terminal/tab ref mirrors covered by #3250; tasks route client ref mirror covered by #3253; tasks route workspace agent selection covered by #3261. Continue with large `tasks.tsx` state/polling Effects, session route cleanup Effects, home route ref mirrors, and remote-client parity. | -| Mobile shared source | `mobile/src/**` | Inventory complete, manual review in progress | TextInputModal and CustomKeyModal open-state resets covered by #3223; BottomDrawer mount bookkeeping covered by #3226; MobileBrowserPane address sync covered by #3228; MobileBrowserPane ref mirrors covered by #3230; useMobileDictation ref mirrors covered by #3234; NewWorktreeModal agent selection covered by #3238. Continue with transport client context plus NewWorktreeModal open/reset, SSH, agent-detection, and setup-hook fetch Effects. | -| Expo two-way audio hook | `mobile/packages/expo-two-way-audio/src/hooks.ts` | Inventory complete, manual review pending | Single Effect plus `useSyncExternalStore`; verify native subscription cleanup. | -| Tests with hook mocks | `*.test.ts`, `*.test.tsx`, e2e comments | Inventory complete, manual review pending | Do not count comment-only mentions as app Effect sites; update tests beside behavior changes. | - -## Current Findings Queue - -These are candidate batches, not final conclusions. Each item needs code inspection before implementation. - -| Candidate PR | Area | Symptom / wasted work to prove | Likely files | Merge risk | -| ------------ | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------- | -| PR A | Small controlled-input resets | Extra render pass from Effects that mirror one prop into local draft state. | `BrowserAddressBar.tsx` covered by #3038; `BrowserFind.tsx` covered by #3275; continue with `PdfFind.tsx`, selected settings draft fields | Low to medium | -| PR B | Settings draft hydration | Multiple independent Effects mirror persisted settings into draft state. | `components/settings/**` | Medium | -| PR C | Browser pane Effect cluster | Large Effect cluster may mix external webview sync, derived UI state, and event-specific state repair. | `BrowserPane.tsx`, `useGrabMode.ts`, browser tabs | High | -| PR D | Terminal tab repair and lifecycle | Effects repair active terminal/browser tab state after render; could cause extra render and focus churn. | `Terminal.tsx`, `TerminalPane.tsx` | High | -| PR E | Right-sidebar polling and source-control state repair | PR checks, branch snapshots, file explorer watch/reveal may repeat work after unrelated state changes. | `ChecksPanel.tsx`, `SourceControl.tsx`, `useGitStatusPolling.ts`, file explorer hooks | High | -| PR F | Mobile route monoliths | Mobile task/session routes have many chained Effects and timer/refetch patterns. | `mobile/app/h/[hostId]/tasks.tsx`, `mobile/app/h/[hostId]/session/[worktreeId].tsx` | High | -| PR G | Mobile shared components | Visible/open Effects reset modal/drawer state and browser address state. | `mobile/src/components/**`, `mobile/src/browser/MobileBrowserPane.tsx` | Medium | -| PR H | Editor search/preview/decorations | Debounce and scroll-restore Effects may be legitimate external sync but need cleanup/count review. | `components/editor/**` | Medium to high | -| PR I | Feature wall/onboarding demos | Animation/demo Effects can often move to event handlers or tighter custom hooks. | `components/feature-wall/**`, `components/onboarding/**` | Low to medium | -| PR J | GitHub filter controls | Extra render pass from mirroring parsed reviewer qualifier into local mode state. | `PRFilterDropdowns.tsx` covered by #3041 | Low | -| PR K | Sidebar project filter | Extra render pass from mirroring the first filtered repo into command selection state. | `SidebarFilter.tsx` covered by #3042 | Low | -| PR L | Diff note edit draft | Extra render pass from mirroring saved note body into edit draft while not editing. | `DiffCommentCard.tsx` covered by #3044 | Low | -| PR M | Status-bar account menus | Extra render pass from closing Claude/Codex account submenus in Effects after the provider menu closes. | `StatusBar.tsx` covered by #3051 | Low | -| PR N | Browser tab favicon fallback | Extra render pass from resetting failed favicon state in an Effect after tab favicon identity changes. | `BrowserTab.tsx` covered by #3052 | Low | -| PR O | Workspace title rename draft | Redundant draft sync Effect runs while the inline title rename input is not mounted. | `WorktreeTitleInlineRename.tsx` covered by #3053 | Low | -| PR P | Feature-wall tour static substep repair | Three no-op Effects scan static step arrays and repair ids that are only written from those arrays. | `FeatureWallTourSurface.tsx` covered by #3054 | Low | -| PR Q | Repo combobox mount-open state | Mount-only auto-open path uses an Effect and ref guard instead of initializing state from the prop. | `RepoCombobox.tsx` covered by #3055 | Low | -| PR R | Quick Open query reset | Extra render pass from clearing the Quick Open input after the dialog opens. | `QuickOpen.tsx` covered by #3056 | Low | -| PR S | Project group dialog open resets | Extra render pass from seeding name/delete dialog local state after the dialog opens. | `ProjectGroupNameDialog.tsx`, `ProjectGroupDeleteDialog.tsx` covered by #3058 | Low | -| PR T | Onboarding agent fallback disclosure | Extra render pass from opening the fallback agent list when a selected agent first appears there. | `AgentStep.tsx` covered by #3059 | Low | -| PR U | Feature-wall tour workflow resets | Four local reset Effects run after workflow changes or close instead of in the selection/close path. | `FeatureWallTourSurface.tsx` covered by #3060 | Low | -| PR V | Workspace board selection pruning | Local kanban selection is repaired in an Effect after the drawer closes or board rows change. | `use-workspace-kanban-selection.ts` covered by #3062 | Low | -| PR W | Workspace board column resize | Two mirror Effects sync the latest commit callback and external committed width after render. | `use-workspace-kanban-column-resize.ts` covered by #3063 | Low | -| PR X | Terminal quick-command dialog draft | Dialog draft and agent preset search are reset in an Effect after the dialog opens or retargets. | `TerminalQuickCommandDialog.tsx` covered by #3064 | Low | -| PR Y | Onboarding notification settings ref | A ref mirror Effect keeps notification handlers pointed at the latest settings. | `NotificationStep.tsx` covered by #3065 | Low | -| PR Z | Onboarding agent-selection ref mirrors | Five ref mirror Effects keep stable onboarding handlers pointed at latest selection/detection snapshots. | `use-onboarding-flow.ts` covered by #3066 | Low | -| PR AA | Workspace board drag ref mirrors | Area-selection and card-drag pointer handlers receive latest board callbacks through Effect-updated refs. | `use-workspace-kanban-area-selection.ts`, `use-workspace-kanban-card-pointer-drag.ts` covered by #3067 | Low | -| PR AB | Feature-wall tour telemetry refs | Close telemetry reads source/depth payload inputs through Effect-updated refs. | `use-feature-wall-tour-telemetry.ts` covered by #3068 | Low | -| PR AC | Onboarding persisted theme ref | The theme cleanup ref mirrors the latest persisted setting through an Effect. | `use-onboarding-flow.ts` covered by #3069 | Low | -| PR AD | Terminal window blur restart snapshot | A mount-only Effect rewrites the same startup blur snapshot already captured by the lazy ref initializer. | `TerminalWindowSection.tsx` covered by #3070 | Medium | -| PR AE | New-workspace composer note ref | A note ref mirror Effect runs after each note edit so stable PR/MR selection callbacks can read latest text. | `useComposerState.ts` covered by #3073 | Medium | -| PR AF | Source-control selection ref mirrors | Four ref mirror Effects keep stable source-control row handlers pointed at latest selection inputs. | `useSourceControlSelection.ts` covered by #3075 | High | -| PR AG | Notebook cell editor callback refs | A ref mirror Effect keeps embedded Monaco save/deactivate handlers pointed at latest callbacks. | `IpynbViewer.tsx` covered by #3077 | Medium | -| PR AH | Rich markdown editor ref mirrors | Nine ref mirror Effects keep stable ProseMirror/menu handlers pointed at latest editor UI state. | `RichMarkdownEditor.tsx` covered by #3079 | Medium | -| PR AI | Browser pane ref mirrors | Eight ref mirror Effects keep browser/webview callbacks and latest tab state available to stable handlers. | `BrowserPane.tsx`, `useGrabMode.ts` covered by #3081 | High | -| PR AJ | Untitled markdown rename draft reset | Save-as dialog draft fields reset in an Effect after open instead of before the first open paint. | `UntitledFileRenameDialog.tsx` covered by #3083 | Low | -| PR AK | Onboarding/feature-wall select portal roots | Two mount Effects query the active overlay/dialog root only to keep select menus above fullscreen surfaces. | `NotificationStep.tsx`, `AiCommitPrSettingsCard.tsx` covered by #3087 | Low | -| PR AL | Orca hook trust dialog checkbox reset | The "always trust" checkbox resets after open, leaving one possible paint with the previous decision. | `OrcaYamlTrustDialog.tsx` covered by #3089 | Medium | -| PR AM | Confirmation dialog active request mirror | Active confirmation request was mirrored into local state/ref in an Effect, adding a render pass per request. | `confirmation-dialog.tsx` covered by #3091 | Low | -| PR AN | Repository hook local editor visibility | The local hook script editor was revealed by an Effect after persisted local script content arrived. | `RepositoryHooksSection.tsx` covered by #3093 | Medium | -| PR AO | Feature-wall tour visited markers | Four Effects marked visible workflows/substeps as visited after render instead of in the open/select/continue transitions. | `FeatureWallTourSurface.tsx` covered by #3095 | Medium | -| PR AP | Editor copy feedback timers | Copy success indicators started their auto-clear timers in Effects after render rather than in the copy handlers. | `EditorPanel.tsx`, `MarkdownPreview.tsx` covered by #3097 | Medium | -| PR AQ | Combobox open focus | Four comboboxes focused search inputs and seeded cmdk highlight state in Effects after opening. | `AgentCombobox.tsx`, `RepoCombobox.tsx`, `WorkspaceCombobox.tsx`, `CreateFromPicker.tsx` covered by #3100 | Low | -| PR AR | Automation run-table selection repair | External automation run selection was repaired in an Effect after the visible run page changed. | `ExternalAutomationRunTable.tsx` covered by #3102 | Medium | -| PR AS | Automation run-history selection repair | Run-history row selection was repaired in an Effect after the automation run list changed. | `AutomationRunHistory.tsx` covered by #3104 | Low | -| PR AT | Automation detail-page selection resets | Internal and external automation run detail pages were cleared in Effects after selected automation/external keys changed. | `AutomationsPage.tsx` covered by #3106 | Medium | -| PR AU | Linear estimate input sync | Linear custom estimate input was mirrored from the issue estimate in an Effect while the popover was closed. | `LinearItemDrawer.tsx` covered by #3108 | Medium | -| PR AV | Linear sub-issue list sync | Linear sub-issue rows were mirrored from the selected issue in an Effect so optimistic creates could append local rows. | `LinearIssueWorkspace.tsx` covered by #3110 | Medium | -| PR AW | GitHub project date draft sync | GitHub Project date cells mirrored the source field value into local input draft state in an Effect. | `ProjectCell.tsx` covered by #3112 | Medium | -| PR AX | GitHub reviewer picker active index | Three GitHub review request pickers reset active keyboard selection in Effects after query or row-count changes. | `PullRequestPage.tsx`, `GitHubItemDialog.tsx`, `TaskPage.tsx` covered by #3114 | Medium | -| PR AY | GitHub Project column state | Project view column visibility and width preferences were reloaded in Effects after project/view scope changes. | `ProjectViewList.tsx` covered by #3116 | Medium | -| PR AZ | Pet overlay size clamp | Pet overlay position was repaired in an Effect after the persisted overlay size changed. | `PetOverlay.tsx` covered by #3118 | Medium | -| PR BA | Combined diff local state repairs | File-tree highlight and clear-notes dialog state were repaired in Effects after diff entry or note-count changes. | `CombinedDiffViewer.tsx` covered by #3120 | Medium | -| PR BB | Onboarding settings detour reset | The App-root onboarding detour flag was cleared in an Effect after Settings navigation or onboarding eligibility changed. | `App.tsx` covered by #3122 | Low | -| PR BC | GitLab comment draft reset | The GitLab MR/issue comment draft was cleared in an Effect after the selected sheet item changed. | `GitLabItemDialog.tsx` covered by #3124 | Medium | -| PR BD | Activity selected-thread repair | Activity selected thread state was cleared in an Effect after retained agent/tab rows changed. | `ActivityPrototypePage.tsx` covered by #3126 | Medium | -| PR BE | Settings lazy-section mounted state | Settings remembered newly needed lazy sections in an Effect after search/navigation changed the visible set. | `Settings.tsx` covered by #3128 | Medium | -| PR BF | Linear title/description draft sync | Linear issue title and description drafts were synced from source issue data in an Effect after item or field updates. | `LinearIssueTextEditor.tsx` covered by #3130 | Medium | -| PR BG | Automations create-from branch search | Create-from branch search query/results were cleared in an Effect after the selected repo changed. | `CreateFromPicker.tsx` covered by #3132 | Medium | -| PR BH | Delete dialog one-shot confirmation | The delete-workspace "don't ask again" checkbox was reset in an Effect after the dialog closed. | `DeleteWorktreeDialog.tsx` covered by #3134 | Medium | -| PR BI | Browser settings text drafts | Browser home-page and Kagi session-link drafts were synced from persisted settings in Effects after source changes. | `BrowserPane.tsx`, `KagiSessionLinkForm.tsx` covered by #3136 | Medium | -| PR BJ | General settings drafts | Auto-save delay and Open In launcher drafts were synced from persisted settings in Effects after source changes. | `GeneralPane.tsx` covered by #3138 | Medium | -| PR BK | Source Control AI instruction drafts | Commit-message, pull-request, and branch-name instruction drafts plus parent discard reset were synchronized in Effects. | `CommitMessageAiPane.tsx` covered by #3140 | Medium | -| PR BL | Notification volume draft | Notification custom-sound volume draft and latest-settings ref were synchronized in an Effect after settings changes. | `NotificationsPane.tsx` covered by #3142 | Medium | -| PR BM | Repo Source Control AI override draft | Repo-specific Source Control AI override draft was synchronized from persisted repo settings in an Effect after source changes. | `RepositorySourceControlAiSection.tsx` covered by #3144 | Medium | -| PR BN | Quick Commands add intent | Quick Commands add-dialog deep-link intent opened from a settings signal in an Effect after the pane rendered. | `QuickCommandsPane.tsx` covered by #3146 | Medium | -| PR BO | Quick workspace agent selection | Quick-create agent override was repaired in an Effect after detection or disabled-agent settings invalidated it. | `NewWorkspaceComposerModal.tsx`, `quick-workspace-agent-selection.ts` covered by #3148 | Medium | -| PR BP | Add Repo clone destination default | Clone destination was auto-filled in an Effect after entering the local clone step or after settings hydration. | `AddRepoDialog.tsx`, `clone-defaults.ts` covered by #3150 | Medium | -| PR BQ | Mobile QR pairing poll count | The paired-device count used to start QR polling was captured in an Effect after the QR data URL rendered. | `MobilePane.tsx` covered by #3152 | Medium | -| PR BR | Integrations preflight status mirrors | Source-host integration statuses and account labels were mirrored from preflight store state in an Effect after Settings rendered. | `IntegrationsPane.tsx`, `integrations-pane-status.ts` covered by #3154 | Medium | -| PR BS | SSH reset-relay dialog cleanup | Pending reset-relay confirmation was cleared in an Effect after the selected target began connecting or reconnecting. | `SshTargetDestructiveActions.tsx`, `ssh-target-action-state.ts` covered by #3157 | High | -| PR BT | Sidebar live-width mirror | Sidebar root mirrored the committed width into the live drag CSS variable in a second passive Effect. | `components/sidebar/index.tsx` covered by #3160 | Low | -| PR BU | Cmd+J selected-row repair | The jump palette repaired selected row state in an Effect after query or result-list changes. | `WorktreeJumpPalette.tsx`, `worktree-palette-create-action.test.ts` covered by #3164 | Low | -| PR BV | Sidebar selected-worktree pruning | The sidebar pruned selected worktree ids in an Effect after filters or grouping hid selected rows. | `WorktreeList.tsx` covered by #3166 | Low | -| PR BW | Workspace Space row-state repairs | The space manager repaired inspected row, selected ids, and treemap zoom in Effects after scan rows changed. | `WorkspaceSpaceManagerPanel.tsx`, `workspace-space-presentation.ts` covered by #3168 | Medium | -| PR BX | Mobile driver overlay collapse reset | The terminal mobile-driver overlay re-expanded from collapsed chip mode in an Effect after driver changes. | `MobileDriverOverlay.tsx`, `mobile-driver-overlay-collapse.ts` covered by #3170 | Medium | -| PR BY | Diagnostics bundle cleanup ref | The retained diagnostics bundle id was mirrored into a cleanup ref by a passive Effect after bundle creation. | `PrivacyDiagnosticsSection.tsx` covered by #3176 | Medium | -| PR BZ | Mobile pairing page stage repairs | Mobile page polling flipped from QR flow to paired summary, then reset the paired-view polling baseline, in two follow-up Effects. | `MobilePage.tsx`, `mobile-page-stage.ts` covered by #3180 | Medium | -| PR CA | Create PR submit close reset | The hosted review creation dialog reset submit/error state in an Effect after closing. | `CreatePullRequestDialog.tsx`, `ChecksPanel.tsx` covered by #3184 | Medium | -| PR CB | GitHub Project visible-table cache | The last filtered GitHub Project table was cached in state by an Effect after slug-index filtering completed. | `ProjectViewWrapper.tsx`, `project-visible-table-cache.ts` covered by #3186 | Medium | -| PR CC | Feature-wall persisted completion close reset | Persisted feature-wall completion sets were reread from localStorage in an Effect after the modal closed. | `use-persisted-feature-wall-completion.ts` covered by #3188 | Medium | -| PR CD | Smart workspace command highlight | The smart new-workspace picker repaired cmdk command highlight in an Effect after rows, query freshness, or source intent changed. | `SmartWorkspaceNameField.tsx`, `smart-workspace-command-value.ts` covered by #3192 | Medium | -| PR CE | Workspace cleanup active view | The cleanup dialog repaired the active nav section in an Effect after scan/filter counts made the requested section empty. | `WorkspaceCleanupDialog.tsx`, `workspace-cleanup-view-selection.ts` covered by #3195 | Medium | -| PR CF | Onboarding settings hydration | The onboarding wizard re-synced saved theme/default-agent settings in an Effect after async settings hydration. | `use-onboarding-flow.ts`, `onboarding-settings-hydration.ts` covered by #3198 | Medium | -| PR CG | Resource Manager Space scan-ready latch | The status-bar Resource Manager ready dot was repaired in an Effect after Space scan completion, popover open, or Space page navigation. | `ResourceUsageStatusSegment.tsx`, `resource-usage-space-scan-ready.ts` covered by #3200 | Medium | -| PR CH | Commit failure dialog identity | The Source Control commit failure details dialog reset stale open state in an Effect after the active failure identity changed. | `SourceControl.tsx`, `commit-failure-dialog-state.ts` covered by #3202 | Medium | -| PR CI | Diff comments clear confirmation | The Source Control note-clear confirmation closed stale pending state in an Effect after active worktree or note counts changed. | `SourceControl.tsx`, `diff-comments-clear-dialog-state.ts` covered by #3206 | Medium | -| PR CJ | Diff notes copy feedback timers | The Source Control note copy success indicators started auto-dismiss timers in Effects after copy handlers set copied state. | `SourceControl.tsx` covered by #3210 | Medium | -| PR CK | Source Control selected-key pruning | Bulk selection keys and range anchors were pruned in an Effect after visible Source Control rows changed. | `useSourceControlSelection.ts` covered by #3212 | High | -| PR CL | GitHub Project dialog state | Project repo-backed and slug fallback dialogs were closed in Effects after repo membership or slug-index readiness changed. | `ProjectViewWrapper.tsx`, `project-dialog-state.ts` covered by #3214 | Medium | -| PR CM | Combined diff note-copy timer | The combined diff note-copy success indicator started its auto-dismiss timer in an Effect after the copy handler set copied state. | `CombinedDiffViewer.tsx` covered by #3218 | Medium | -| PR CN | App lazy-modal mount state | Root lazy modal ids were retained in an Effect after `activeModal` changed, adding a render pass before first-use modal mount. | `App.tsx`, `lazy-modal-mount-state.ts` covered by #3220 | Medium | -| PR CO | Mobile shared modal open resets | Mobile TextInput and custom-key modals reset local drafts in Effects after opening, leaving one possible stale first paint. | `TextInputModal.tsx`, `CustomKeyModal.tsx` covered by #3223 | Low | -| PR CP | Mobile BottomDrawer mounted state | The shared mobile drawer mounted after opening in a passive Effect, adding a null commit before drawer animations could start. | `BottomDrawer.tsx`, `bottom-drawer-mount-state.ts` covered by #3226 | Medium | -| PR CQ | Mobile browser address sync | The mobile browser address field synced tab URL changes in an Effect after focus/tab updates, leaving one stale paint after blur or tab URL changes. | `MobileBrowserPane.tsx`, `mobile-browser-address-sync.ts` covered by #3228 | Medium | -| PR CR | Mobile browser ref mirrors | Mobile browser gesture and stream handlers received frame metadata, layout, dialog, and zoom state through passive ref-mirror Effects. | `MobileBrowserPane.tsx` covered by #3230 | Medium | -| PR CS | Mobile dictation ref mirrors | Native audio/RPC dictation handlers received client, enabled, transcript, and error callbacks through a passive ref-mirror Effect. | `use-mobile-dictation.ts` covered by #3234 | Medium | -| PR CT | Mobile new-worktree agent selection | The create-workspace modal repaired preferred and unavailable selected-agent state in passive Effects after settings or agent detection changed. | `NewWorktreeModal.tsx`, `new-worktree-agent-selection.ts` covered by #3238 | Medium | -| PR CU | Mobile pair-confirm route parsing | The deep-link pair-confirm screen rendered once without an offer/error, then parsed the route pairing code and repaired status in a passive Effect. | `pair-confirm.tsx`, `pair-confirm-state.ts` covered by #3240 | Medium | -| PR CV | Mobile host new-worktree route action | Host home opened the create-workspace modal from `action=newWorktree` in a passive Effect after the route rendered. | host route index, `host-route-action-state.ts` covered by #3242 | Medium | -| PR CW | Mobile Accounts unused client ref | The Accounts route mirrored `client` into a ref in a passive Effect, but no code read that ref. | `mobile/app/h/[hostId]/accounts.tsx` covered by #3244 | Low | -| PR CX | Mobile home shared-client ref mirror | The home route copied shared host clients into a focus-callback ref in a passive Effect, delaying focus refreshes by one commit. | `mobile/app/index.tsx` covered by #3247 | Medium | -| PR CY | Mobile session terminal/tab ref mirrors | The mobile session route copied active tab, tab list, markdown doc, client, and connection snapshots into imperative refs in passive Effects after terminal input/session callbacks could already run. | `mobile/app/h/[hostId]/session/[worktreeId].tsx` covered by #3250 | High | -| PR CZ | Mobile tasks client ref mirror | The mobile Tasks route copied the shared client into a stale-request guard ref in a passive Effect, delaying guard freshness for async task loads. | `mobile/app/h/[hostId]/tasks.tsx` covered by #3253 | High | -| PR DA | GitHub assignee dirty-flag reset | GitHub issue/PR edit sections reset the optimistic assignee-edit guard in a passive Effect after an item switch, so the assignee sync Effect could skip the new item for one commit. | `GitHubItemDialog.tsx`, `PullRequestPage.tsx` covered by #3255 | Medium | -| PR DB | DiffViewer first-diff auto-scroll reset | DiffViewer reset its one-shot first-diff scroll flag in a layout Effect after `modelKey` changed. | `DiffViewer.tsx` covered by #3257 | Medium | -| PR DC | GitHub reviewer-request local sync | GitHub reviewer request pickers mirrored incoming `reviewRequests` into optimistic local state in passive Effects after item switches/refetches. | `GitHubItemDialog.tsx`, `PullRequestPage.tsx`, `TaskPage.tsx` covered by #3259 | Medium | -| PR DD | Mobile Tasks workspace agent selection | The workspace-create drawer repaired automatic and unavailable agent choices in passive Effects after settings or SSH/local detection changed. | `mobile/app/h/[hostId]/tasks.tsx`, `workspace-agent-selection.ts` covered by #3261 | Medium | -| PR DE | Sidebar SSH reconnect prompts | Active sidebar worktree and nested lineage-child reconnect prompts opened from passive Effects after disconnected SSH state rendered. | `WorktreeCard.tsx`, `WorktreeList.tsx` covered by #3267 | High | -| PR DF | Floating workspace first-open bounds | Floating workspace panel normalized first-open bounds in a passive Effect after the initial hidden/open commit. | `FloatingTerminalPanel.tsx` covered by #3269 | Medium | -| PR DG | Unused terminal shell hooks | Four React Effect call sites lived only in unused terminal shell/shortcut/hook files. | `TerminalShell.tsx`, `useTerminalShortcuts.ts`, `useTerminalTabs.ts` covered by #3271 | Low | -| PR DH | Unused feature-wall notifications page | Two React Effect call sites lived only in an unused feature-wall orchestration page. | `NotificationsPage.tsx` covered by #3273 | Low | -| PR DI | Browser find debounce state | The webview find bar stored a debounced copy of query state through a second passive Effect before searching. | `BrowserFind.tsx` covered by #3275 | Medium | -| PR DJ | Editor path-copy feedback timer | The editor header path-copy toast started its auto-dismiss timer in a passive Effect after the copy handler set copied state. | `EditorPanel.tsx` covered by #3277 | Medium | -| PR DK | Feature-wall skill installed state | Orchestration and browser-use setup cards notified the tour parent of installed state from passive Effects after render. | `FeatureWallTourSurface.tsx`, `FeatureWallBody.tsx`, `FeatureWallTourPanel.tsx`, `OrchestrationSetupCard.tsx`, `BrowserUseSkillSetupCard.tsx`, `useInstalledAgentSkills.ts` covered by #3281 | Medium | -| PR DL | Feature-wall session-depth publishing | Session-depth reset and summary-publish Effects both published the same tour-depth summary on the modal open edge. | `use-feature-wall-session-depth.ts` covered by #3283 | Medium | -| PR DM | GitHub link-copy feedback timers | GitHub issue/PR link-copy buttons started their auto-dismiss timers from copied-state Effects after the copy handler set local feedback state. | `GitHubItemDialog.tsx`, `PullRequestPage.tsx` covered by #3285 | Medium | -| PR DN | Shared reduced-motion media query hook | UpdateCard, PetOverlay, and feature-wall/onboarding helpers each owned separate media-query subscription Effects for the same reduced-motion preference. | `usePrefersReducedMotion.ts`, `UpdateCard.tsx`, `PetOverlay.tsx`, `feature-wall-modal-helpers.ts` covered by #3287 | Medium | -| PR DO | PDF find open/reset state | PDF find focused the input and closed/reset match counters from a passive open-state Effect after the find bar rendered or hid. | `PdfFind.tsx`, `PdfViewer.tsx` covered by #3289 | Medium | -| PR DP | GitLab task filter repair | Switching between GitLab MR and issue views repaired invalid stale chip filters in a passive Effect after render. | `TaskPage.tsx` covered by #3292 | Medium | -| PR DQ | Nested repo select-all indeterminate state | The nested repo select-all checkbox set its visual indeterminate state in a passive Effect after selection changes. | `NestedRepoTreePreview.tsx` covered by #3294 | Low | -| PR DR | Workspace title rename focus | Inline workspace title rename focused and selected its input from a passive Effect after edit mode rendered. | `WorktreeTitleInlineRename.tsx` covered by #3296 | Low | -| PR DS | Workspace title editing notification | Inline workspace title rename notified parent cards that editing mode changed from a passive Effect after the rename input rendered. | `WorktreeTitleInlineRename.tsx` covered by #3298 | Low | -| PR DT | Worktree metadata notes resize | The worktree metadata notes textarea resized itself from a passive Effect after the comment draft rendered. | `WorktreeMetaDialog.tsx` covered by #3300 | Low | -| PR DU | Editor tab rename focus | Editor file-tab rename focused and selected the filename input from a passive Effect after rename mode rendered. | `EditorFileTab.tsx` covered by #3303 | Medium | -| PR DV | Tab entry selected action reset | The new tab-entry menu reset its highlighted action in a passive Effect after the query changed. | `TabBarCreateEntry.tsx` covered by #3311 | Medium | -| PR DW | Terminal tab rename focus | Terminal tab inline rename focused and selected its input from a passive Effect after edit mode rendered. | `SortableTab.tsx` covered by #3314 | Medium | -| PR DX | Rich markdown link bubble focus | The link-edit bubble focused and selected the URL input from a passive Effect after edit mode mounted. | `RichMarkdownLinkBubble.tsx` covered by #3316 | Medium | -| PR DY | Image viewer preview source | Image previews built a blob URL and reset stale load-error state from a passive Effect after the editor rendered. | `ImageViewer.tsx` covered by #3318 | Medium | -| PR DZ | Markdown preview search focus | Markdown preview search focused and selected the find input from a passive Effect after search mode rendered. | `MarkdownPreview.tsx` covered by #3322 | Medium | -| PR EA | Rich markdown menu selection | Slash-menu and doc-link-menu selected indexes were reset, clamped, and mirrored into key-handler refs from passive Effects after menu/query changes. | `RichMarkdownEditor.tsx` covered by #3326 | Medium | -| PR EB | Editor header rename focus | Header inline-file rename focused and selected the filename input from a passive Effect after rename mode rendered. | `editor-header-file-rename.ts` covered by #3328 | Medium | -| PR EC | File search input focus | The right-sidebar file-search input focused itself from a passive Effect after the Search panel mounted. | `Search.tsx`, `SearchHeader.tsx` covered by #3330 | Low | -| PR ED | Diff comment popover focus | The diff-comment popover textarea focused itself from a passive Effect after the note popover mounted. | `DiffCommentPopover.tsx` covered by #3333 | Medium | -| PR EE | Markdown annotation composer focus | The markdown-preview annotation composer focused its note textarea from a passive Effect after the composer mounted. | `MarkdownPreview.tsx` covered by #3338 | Medium | -| PR EF | Status-bar inactive account usage fetch | Expanding the Claude account switcher kicked off inactive-account usage loading from a passive Effect after the submenu rendered. | `StatusBar.tsx` covered by #3340 | Medium | -| PR EG | Terminal search focus | Opening terminal search focused the input from a passive Effect before the remaining search-sync Effect handled xterm decoration cleanup. | `TerminalSearch.tsx` covered by #3344 | Medium | -| PR EH | File explorer inline input focus | File Explorer create/rename inputs used a passive Effect to focus/select and start the blur grace-window timer after the inline row mounted. | `FileExplorerRow.tsx` covered by #3347 | Medium | -| PR EI | Font autocomplete highlighted-option scroll | Settings font autocomplete scrolled the highlighted option from a passive Effect after each keyboard or pointer highlight change. | `SettingsFormControls.tsx` covered by #3349 | Medium | -| PR EJ | SSH passphrase prompt focus | New SSH password/passphrase requests focused the credential input from a passive Effect keyed by request id. | `SshPassphraseDialog.tsx` covered by #3351 | High | -| PR EK | Diff comment card resize | Diff comment cards remeasured Monaco view-zone content from a passive editing-state Effect after save or cancel closed edit mode. | `DiffCommentCard.tsx` covered by #3354 | Medium | -| PR EL | Contextual-copy toast cleanup | The editor contextual-copy hook used a passive mount Effect only to clear its confirmation-toast timeout on unmount. | `useContextualCopySetup.tsx`, `setup-contextual-copy.ts` covered by #3356 | Medium | -| PR EM | Space page open interaction | The Space page recorded workspace-cleanup feature usage from a passive mount Effect after the page rendered. | `WorkspaceSpacePage.tsx`, `ui.ts` covered by #3361 | Medium | -| PR EN | Workspace board open interaction | The workspace board drawer recorded feature usage from a passive open-state Effect after the drawer rendered. | `SidebarHeader.tsx`, `WorkspaceKanbanDrawer.tsx` covered by #3365 | Medium | -| PR EO | Activity terminal portal subscription | Activity terminal portal targets were mirrored from module state through a layout Effect and local state. | `activity-terminal-portal.ts` covered by #3367 | High | -| PR EP | GitHub Project column preferences | GitHub Project hidden-column and column-width preferences reloaded from localStorage in passive Effects after project/view scope changes. | `ProjectViewList.tsx` covered by #3374 | Medium | - -## Merge Risk Scale - -| Risk | Criteria | Required verification | -| ------ | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| Low | Isolated local UI state, no persistence, no IPC, no terminal/browser/source-control behavior. | Targeted unit test if available plus `pnpm run typecheck:web`. | -| Medium | Settings, editor UI, mobile local UI, or state persisted per workspace but no transport protocol changes. | Targeted tests plus manual interaction or focused Playwright/Electron check when UI behavior changes. | -| High | Terminal/PTY, browser webview, source control, mobile remote-client, SSH, polling, persistence, or provider behavior. | Targeted tests, `pnpm run typecheck:web`, and Electron or mobile/manual parity evidence as appropriate. | - -## PR Log - -| PR | Branch | Area | Risk | Status | Evidence | -| ----- | ------------------------------------ | ----------------------------------------------------------------------------- | ---- | ------ | ------------------------------------------------------------------------------------------------------------ | -| #3038 | `nwparker/react-perf` | Audit ledger plus browser address bar top-suggestion mirror Effect removal | Low | Merged | `pnpm exec oxlint src/renderer/src/components/browser-pane/BrowserAddressBar.tsx`; `pnpm run typecheck:web`. | -| #3041 | `nwparker/react-perf-pr-filter` | PR reviewer filter mode derived from parsed query plus explicit user override | Low | Merged | `pnpm exec oxlint src/renderer/src/components/github/PRFilterDropdowns.tsx`; `pnpm run typecheck:web`. | -| #3042 | `nwparker/react-perf-sidebar-filter` | Sidebar project filter command selection derived from filtered repos | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/SidebarFilter.tsx`; `pnpm run typecheck:web`. | -| #3044 | `nwparker/react-perf-low-risk-2` | Diff note card removes saved-body-to-draft mirror Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/diff-comments/DiffCommentCard.tsx`; `pnpm run typecheck:web`. | -| #3051 | `nwparker/react-perf-low-risk-3` | Status-bar account submenus collapse in provider menu open-change handlers | Low | Merged | `pnpm exec oxlint src/renderer/src/components/status-bar/StatusBar.tsx`; `pnpm run typecheck:web`. | -| #3052 | `nwparker/react-perf-low-risk-4` | Browser tab favicon failure reset happens during render for new favicon IDs | Low | Merged | `pnpm exec oxlint src/renderer/src/components/tab-bar/BrowserTab.tsx`; `pnpm run typecheck:web`. | -| #3053 | `nwparker/react-perf-low-risk-5` | Inline workspace-title rename removes inactive draft sync Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx`; `pnpm run typecheck:web`. | -| #3054 | `nwparker/react-perf-low-risk-6` | Feature-wall tour removes static substep id repair Effects | Low | Merged | `pnpm exec oxlint src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx`; `pnpm run typecheck:web`. | -| #3055 | `nwparker/react-perf-low-risk-7` | Repo combobox initializes mount-open state without an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/repo/RepoCombobox.tsx`; `pnpm run typecheck:web`. | -| #3056 | `nwparker/react-perf-low-risk-8` | Quick Open clears its query on the open edge without a reset Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/QuickOpen.tsx`; `pnpm run typecheck:web`. | -| #3058 | `nwparker/react-perf-project-group-dialogs` | Project group dialogs reset local open-state during render | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.tsx src/renderer/src/components/sidebar/ProjectGroupNameDialog.tsx`; `pnpm run typecheck:web`. | -| #3059 | `nwparker/react-perf-agent-step-latch` | Onboarding agent fallback disclosure latch updates during render | Low | Merged | `pnpm exec oxlint src/renderer/src/components/onboarding/AgentStep.tsx`; `pnpm run typecheck:web`. | -| #3060 | `nwparker/react-perf-feature-tour-resets` | Feature-wall tour reset state moves into workflow/close transitions | Low | Merged | `pnpm exec oxlint src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx`; `pnpm run typecheck:web`. | -| #3062 | `nwparker/react-perf-kanban-selection` | Workspace board selection pruning moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/use-workspace-kanban-selection.ts`; `pnpm run typecheck:web`. | -| #3063 | `nwparker/react-perf-kanban-column` | Workspace board column width mirror Effects move to render-time synchronization | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/use-workspace-kanban-column-resize.ts`; `pnpm run typecheck:web`. | -| #3064 | `nwparker/react-perf-quick-command-dialog` | Terminal quick-command dialog resets draft state during render | Low | Merged | `pnpm exec oxlint src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx`; `pnpm run typecheck:web`. | -| #3065 | `nwparker/react-perf-notification-step-ref` | Onboarding notification settings ref mirror moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/onboarding/NotificationStep.tsx`; `pnpm run typecheck:web`. | -| #3066 | `nwparker/react-perf-onboarding-agent-refs` | Onboarding agent-selection ref mirrors move out of Effects | Low | Merged | `pnpm exec oxlint src/renderer/src/components/onboarding/use-onboarding-flow.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/onboarding/agent-picked-payload.test.ts`; `pnpm run typecheck:web`. | -| #3067 | `nwparker/react-perf-kanban-drag-refs` | Workspace board drag ref mirrors move out of Effects | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.test.ts src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts`; `pnpm run typecheck:web`. | -| #3068 | `nwparker/react-perf-feature-wall-telemetry-ref` | Feature-wall tour telemetry ref mirror moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.test.ts`; `pnpm run typecheck:web`. | -| #3069 | `nwparker/react-perf-onboarding-theme-ref` | Onboarding persisted theme ref mirror moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/onboarding/use-onboarding-flow.ts`; `pnpm run typecheck:web`. | -| #3070 | `nwparker/react-perf-terminal-window-ref` | Terminal window blur startup snapshot removes redundant mount Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/TerminalWindowSection.tsx`; `pnpm run typecheck:web`. | -| #3073 | `nwparker/react-perf-composer-note-ref` | New-workspace composer note ref mirror moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/hooks/useComposerState.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/composer-branch-selection.test.ts`; `pnpm run typecheck:web`. | -| #3075 | `nwparker/react-perf-source-control-selection-refs` | Source-control selection ref mirrors move out of Effects | High | Open | `pnpm exec oxlint src/renderer/src/components/right-sidebar/useSourceControlSelection.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts`; `pnpm run typecheck:web`. | -| #3077 | `nwparker/react-perf-ipynb-cell-editor-refs` | Notebook cell editor callback refs move out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/IpynbViewer.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/ipynb-parse.test.ts`; `pnpm run typecheck:web`. | -| #3079 | `nwparker/react-perf-rich-markdown-refs` | Rich markdown editor ref mirrors move out of Effects | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/RichMarkdownEditor.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/rich-markdown-key-handler.test.ts src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx src/renderer/src/components/editor/markdown-doc-completions.test.ts src/renderer/src/components/editor/rich-markdown-commands.test.ts`; `pnpm run typecheck:web`. | -| #3081 | `nwparker/react-perf-browser-ref-mirrors` | Browser pane ref mirrors move out of Effects | High | Open | `pnpm exec oxlint src/renderer/src/components/browser-pane/useGrabMode.ts src/renderer/src/components/browser-pane/BrowserPane.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/browser-pane-page-selection.test.ts src/renderer/src/components/browser-pane/context-menu-positioning.test.ts`; `pnpm run typecheck:web`; Electron sanity launch/attach on CDP port 9333, branch identity confirmed, zero console errors, existing browser webview target observed. | -| #3083 | `nwparker/react-perf-untitled-rename-dialog` | Untitled markdown rename dialog draft reset moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/editor/UntitledFileRenameDialog.tsx`; `pnpm run typecheck:web`. | -| #3087 | `nwparker/react-perf-tour-select-portals` | Onboarding/feature-wall select portal roots move out of Effects | Low | Merged | `pnpm exec oxlint src/renderer/src/components/feature-wall/AiCommitPrSettingsCard.tsx src/renderer/src/components/onboarding/NotificationStep.tsx`; `pnpm run typecheck:web`. | -| #3089 | `nwparker/react-perf-orca-yaml-trust-reset` | Orca hook trust checkbox reset moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx`; `pnpm run typecheck:web`. | -| #3091 | `nwparker/react-perf-confirmation-dialog` | Confirmation dialog active request mirror moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/confirmation-dialog.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 933 -> 932. | -| #3093 | `nwparker/react-perf-repo-hooks-local-editor` | Repository hook local editor visibility is derived from user-open state and non-empty local script content | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/RepositoryHooksSection.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 932 -> 931. | -| #3095 | `nwparker/react-perf-feature-wall-visited-events` | Feature-wall tour visited markers move from Effects to open/select/continue handlers | Medium | Open | `pnpm exec oxlint src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 932 -> 928. | -| #3097 | `nwparker/react-perf-editor-copy-timers` | Editor copy feedback clear timers move into the copy handlers | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/MarkdownPreview.tsx src/renderer/src/components/editor/EditorPanel.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 932 -> 930. | -| #3100 | `nwparker/react-perf-combobox-open-focus` | Combobox open focus and cmdk highlight seeding move out of Effects | Low | Merged | `pnpm exec oxlint src/renderer/src/components/agent/AgentCombobox.tsx src/renderer/src/components/repo/RepoCombobox.tsx src/renderer/src/components/automations/WorkspaceCombobox.tsx src/renderer/src/components/automations/CreateFromPicker.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/agent/AgentCombobox.test.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 932 -> 928. | -| #3102 | `nwparker/react-perf-automation-run-selection` | External automation run-table selection repair moves into page navigation/render derivation | Medium | Open | `pnpm exec oxlint src/renderer/src/components/automations/ExternalAutomationRunTable.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 928 -> 927. | -| #3104 | `nwparker/react-perf-automation-history-selection` | Automation run-history row selection is derived during render | Low | Merged | `pnpm exec oxlint src/renderer/src/components/automations/AutomationRunHistory.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 928 -> 927. | -| #3106 | `nwparker/react-perf-automation-page-resets` | Automation detail-page resets move into automation/external selection handlers | Medium | Open | `pnpm exec oxlint src/renderer/src/components/automations/AutomationsPage.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 925. | -| #3108 | `nwparker/react-perf-linear-estimate-input` | Linear estimate input sync moves to popover open | Medium | Open | `pnpm exec oxlint src/renderer/src/components/LinearItemDrawer.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 926. | -| #3110 | `nwparker/react-perf-linear-subissues` | Linear sub-issue rows derive from issue data plus optimistic additions | Medium | Open | `pnpm exec oxlint src/renderer/src/components/LinearIssueWorkspace.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 926. | -| #3112 | `nwparker/react-perf-project-date-draft` | GitHub project date-cell draft sync moves to render-time source reconciliation | Medium | Open | `pnpm exec oxlint src/renderer/src/components/github-project/ProjectCell.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 926. | -| #3114 | `nwparker/react-perf-reviewer-index` | GitHub reviewer picker active index resets during render | Medium | Open | `pnpm exec oxlint src/renderer/src/components/PullRequestPage.tsx src/renderer/src/components/GitHubItemDialog.tsx src/renderer/src/components/TaskPage.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 924. | -| #3116 | `nwparker/react-perf-project-view-columns` | GitHub Project column hidden/width state reloads during render on scope changes | Medium | Open | `pnpm exec oxlint src/renderer/src/components/github-project/ProjectViewList.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 925. | -| #3118 | `nwparker/react-perf-pet-size-clamp` | Pet overlay position clamps during render on size changes | Medium | Open | `pnpm exec oxlint src/renderer/src/components/pet/PetOverlay.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 926. | -| #3120 | `nwparker/react-perf-combined-diff-state` | Combined diff file-tree highlight and clear-notes dialog repair during render | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/CombinedDiffViewer.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 925. | -| #3122 | `nwparker/react-perf-onboarding-detour` | App onboarding settings detour reset moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/App.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 926. | -| #3124 | `nwparker/react-perf-gitlab-comment-draft` | GitLab comment draft reset moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/GitLabItemDialog.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3126 | `nwparker/react-perf-activity-selection` | Activity selected-thread repair moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/activity/ActivityPrototypePage.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3128 | `nwparker/react-perf-settings-mounted-sections` | Settings lazy-section mounted bookkeeping moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/Settings.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3130 | `nwparker/react-perf-linear-text-drafts` | Linear title/description draft sync moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/LinearIssueTextEditor.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3132 | `nwparker/react-perf-create-from-search` | Automations create-from branch search reset moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/automations/CreateFromPicker.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3134 | `nwparker/react-perf-delete-worktree-confirm` | Delete-workspace one-shot confirmation checkbox reset moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3136 | `nwparker/react-perf-browser-settings-drafts` | Browser home-page and Kagi session-link draft sync moves out of Effects | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/BrowserPane.tsx src/renderer/src/components/settings/KagiSessionLinkForm.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 924. | -| #3138 | `nwparker/react-perf-general-settings-drafts` | General auto-save delay and Open In launcher draft sync moves out of Effects | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/GeneralPane.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/GeneralPane.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 924. | -| #3140 | `nwparker/react-perf-commit-ai-drafts` | Source Control AI instruction draft sync and discard reset move out of Effects | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/CommitMessageAiPane.tsx src/renderer/src/components/settings/CommitMessageAiPane.test.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/CommitMessageAiPane.test.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 921. | -| #3142 | `nwparker/react-perf-notification-volume-draft` | Notification volume draft sync moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/NotificationsPane.tsx src/renderer/src/components/settings/NotificationsPane.test.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/NotificationsPane.test.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3144 | `nwparker/react-perf-repo-source-control-ai-draft` | Repo Source Control AI override draft sync moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3146 | `nwparker/react-perf-quick-commands-intent` | Quick Commands add-intent signal consumption moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/QuickCommandsPane.tsx src/renderer/src/components/settings/QuickCommandsPane.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/QuickCommandsPane.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3148 | `nwparker/react-perf-quick-agent-selection` | Quick workspace agent override repair moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/NewWorkspaceComposerModal.tsx src/renderer/src/lib/quick-workspace-agent-selection.ts src/renderer/src/lib/quick-workspace-agent-selection.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/quick-workspace-agent-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3150 | `nwparker/react-perf-add-repo-clone-default` | Add Repo clone destination auto-fill moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/sidebar/AddRepoDialog.tsx src/renderer/src/components/sidebar/clone-defaults.ts src/renderer/src/components/sidebar/clone-defaults.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/clone-defaults.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3152 | `nwparker/react-perf-mobile-qr-polling` | Mobile QR device-count capture moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/MobilePane.tsx src/renderer/src/components/settings/mobile-pairing-device-polling.ts src/renderer/src/components/settings/mobile-pairing-device-polling.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/mobile-pairing-device-polling.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3154 | `nwparker/react-perf-integrations-status` | Integrations preflight status mirrors move out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/IntegrationsPane.tsx src/renderer/src/components/settings/integrations-pane-status.ts src/renderer/src/components/settings/integrations-pane-status.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/integrations-pane-status.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3157 | `nwparker/react-perf-ssh-reset-dialog` | SSH reset-relay dialog cleanup moves out of an Effect | High | Open | `pnpm exec oxlint src/renderer/src/components/settings/SshTargetDestructiveActions.tsx src/renderer/src/components/settings/ssh-target-action-state.ts src/renderer/src/components/settings/ssh-target-action-state.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/ssh-target-action-state.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3160 | `nwparker/react-perf-sidebar-live-width` | Redundant sidebar live-width mirror Effect removed | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/index.tsx src/renderer/src/hooks/useSidebarResize.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useSidebarResize.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3164 | `nwparker/react-perf-worktree-palette-selection` | Cmd+J selected-row repair moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/WorktreeJumpPalette.tsx src/renderer/src/lib/worktree-palette-create-action.ts src/renderer/src/lib/worktree-palette-create-action.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/worktree-palette-create-action.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 925 -> 924. | -| #3166 | `nwparker/react-perf-sidebar-selection-prune` | Sidebar selected-worktree pruning moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/WorktreeList.tsx src/renderer/src/components/sidebar/worktree-multi-selection.ts src/renderer/src/components/sidebar/worktree-multi-selection.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/worktree-multi-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 924 -> 923. | -| #3168 | `nwparker/react-perf-workspace-space-state` | Workspace Space row-state repairs move out of Effects | Medium | Open | `pnpm exec oxlint src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx src/renderer/src/components/status-bar/workspace-space-presentation.ts src/renderer/src/components/status-bar/workspace-space-presentation.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/status-bar/workspace-space-presentation.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 923 -> 920. | -| #3170 | `nwparker/react-perf-mobile-driver-overlay-collapse` | Mobile driver overlay collapse reset moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx src/renderer/src/components/terminal-pane/mobile-driver-overlay-collapse.ts src/renderer/src/components/terminal-pane/mobile-driver-overlay-collapse.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/mobile-driver-overlay-collapse.test.ts src/renderer/src/components/terminal-pane/mobile-driver-overlay-focus.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 923 -> 922. | -| #3176 | `nwparker/react-perf-diagnostics-bundle-ref` | Diagnostics bundle cleanup ref mirror moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/PrivacyDiagnosticsSection.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/PrivacyPane.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 923 -> 922. | -| #3180 | `nwparker/react-perf-mobile-page-stage` | Mobile pairing page stage repairs move into the device refresh path | Medium | Open | `pnpm exec oxlint src/renderer/src/components/mobile/MobilePage.tsx src/renderer/src/components/mobile/mobile-page-stage.ts src/renderer/src/components/mobile/mobile-page-stage.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/mobile/mobile-page-stage.test.ts src/renderer/src/components/settings/mobile-pairing-device-polling.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 923 -> 921. | -| #3184 | `nwparker/react-perf-create-pr-submit-reset` | Create PR dialog submit reset moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx src/renderer/src/components/right-sidebar/ChecksPanel.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 923 -> 922. | -| #3186 | `nwparker/react-perf-project-visible-table-cache` | GitHub Project visible-table cache moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/github-project/ProjectViewWrapper.tsx src/renderer/src/components/github-project/project-visible-table-cache.ts src/renderer/src/components/github-project/project-visible-table-cache.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/github-project/project-visible-table-cache.test.ts src/renderer/src/components/github-project/project-row-filtering.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 923 -> 922. | -| #3188 | `nwparker/react-perf-feature-wall-persisted-reset` | Feature-wall persisted close reset Effect removed | Medium | Open | `pnpm exec oxlint src/renderer/src/components/feature-wall/use-persisted-feature-wall-completion.ts src/renderer/src/components/feature-wall/use-feature-wall-completion.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 923 -> 922. | -| #3192 | `nwparker/react-perf-smart-name-command-value` | Smart workspace command highlight resolves during render | Medium | Open | `pnpm exec oxlint src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx src/renderer/src/components/new-workspace/smart-workspace-command-value.ts src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3195 | `nwparker/react-perf-workspace-cleanup-view` | Workspace cleanup active view resolves during render | Medium | Open | `pnpm exec oxlint src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx src/renderer/src/components/workspace-cleanup/workspace-cleanup-view-selection.ts src/renderer/src/components/workspace-cleanup/workspace-cleanup-view-selection.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/workspace-cleanup/workspace-cleanup-view-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3198 | `nwparker/react-perf-onboarding-settings-hydration` | Onboarding settings hydration resolves before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/components/onboarding/use-onboarding-flow.ts src/renderer/src/components/onboarding/onboarding-settings-hydration.ts src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts src/renderer/src/components/onboarding/agent-picked-payload.test.ts src/renderer/src/components/onboarding/ThemeStep.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3200 | `nwparker/react-perf-resource-space-ready` | Resource Manager Space scan-ready latch resolves before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx src/renderer/src/components/status-bar/resource-usage-space-scan-ready.ts src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3202 | `nwparker/react-perf-commit-failure-dialog` | Source Control commit-failure dialog identity resolves before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/components/right-sidebar/SourceControl.tsx src/renderer/src/components/right-sidebar/commit-failure-dialog-state.ts src/renderer/src/components/right-sidebar/commit-failure-dialog-state.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/commit-failure-dialog-state.test.ts src/renderer/src/components/right-sidebar/CommitArea.test.tsx src/renderer/src/components/right-sidebar/commit-failure-summary.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3206 | `nwparker/react-perf-diff-comments-clear` | Source Control diff-comments clear confirmation resolves before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/components/right-sidebar/SourceControl.tsx src/renderer/src/components/right-sidebar/diff-comments-clear-dialog-state.ts src/renderer/src/components/right-sidebar/diff-comments-clear-dialog-state.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/diff-comments-clear-dialog-state.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3210 | `nwparker/react-perf-diff-comments-copy-timers` | Source Control diff-notes copy reset timers move into copy handlers | Medium | Open | `pnpm exec oxlint src/renderer/src/components/right-sidebar/SourceControl.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 921. | -| #3212 | `nwparker/react-perf-source-selection-reconcile` | Source Control stale selected-key pruning resolves before commit | High | Open | `pnpm exec oxlint src/renderer/src/components/right-sidebar/useSourceControlSelection.ts src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3214 | `nwparker/react-perf-project-dialog-state` | GitHub Project dialog state resolves before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/components/github-project/ProjectViewWrapper.tsx src/renderer/src/components/github-project/project-dialog-state.ts src/renderer/src/components/github-project/project-dialog-state.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/github-project/project-dialog-state.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 921. | -| #3218 | `nwparker/react-perf-combined-diff-copy-timer` | Combined diff note-copy reset timer moves into the copy handler | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/CombinedDiffViewer.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3220 | `nwparker/react-perf-app-lazy-modals` | App lazy-modal mount state resolves before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/App.tsx src/renderer/src/lazy-modal-mount-state.ts src/renderer/src/lazy-modal-mount-state.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lazy-modal-mount-state.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3223 | `nwparker/react-perf-mobile-modal-resets` | Mobile shared modal draft resets move out of Effects | Low | Merged | `pnpm exec oxlint mobile/src/components/TextInputModal.tsx mobile/src/components/CustomKeyModal.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 921. Mobile package-local typecheck blocked by missing Expo/mobile deps/config in this checkout. | -| #3226 | `nwparker/react-perf-bottom-drawer-mounted` | Mobile BottomDrawer mounted state resolves before commit | Medium | Open | `pnpm exec oxlint mobile/src/components/BottomDrawer.tsx mobile/src/components/bottom-drawer-mount-state.ts mobile/src/components/bottom-drawer-mount-state.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `pnpm exec tsx -e "import { strict as assert } from 'node:assert'; import { resolveBottomDrawerMounted } from './mobile/src/components/bottom-drawer-mount-state.ts'; assert.equal(resolveBottomDrawerMounted(true, false), true); assert.equal(resolveBottomDrawerMounted(true, true), true); assert.equal(resolveBottomDrawerMounted(false, true), true); assert.equal(resolveBottomDrawerMounted(false, false), false); console.log('bottom-drawer-mount-state ok')"`; AST Effect count 921 -> 920. Mobile Vitest blocked by missing `expo/tsconfig.base` in this checkout. | -| #3228 | `nwparker/react-perf-mobile-browser-address` | Mobile browser address sync resolves before commit | Medium | Open | `pnpm exec oxlint mobile/src/browser/MobileBrowserPane.tsx mobile/src/browser/mobile-browser-address-sync.ts mobile/src/browser/mobile-browser-address-sync.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `pnpm exec tsx -e "import { strict as assert } from 'node:assert'; import { resolveMobileBrowserAddressSync } from './mobile/src/browser/mobile-browser-address-sync.ts'; assert.deepEqual(resolveMobileBrowserAddressSync({ focused: false, url: 'https://old.example/' }, { focused: false, url: 'https://new.example/' }), { nextState: { focused: false, url: 'https://new.example/' }, shouldSyncValue: true }); assert.deepEqual(resolveMobileBrowserAddressSync({ focused: false, url: 'https://old.example/' }, { focused: true, url: 'https://new.example/' }), { nextState: { focused: true, url: 'https://new.example/' }, shouldSyncValue: false }); assert.deepEqual(resolveMobileBrowserAddressSync({ focused: true, url: 'https://new.example/' }, { focused: false, url: 'https://new.example/' }), { nextState: { focused: false, url: 'https://new.example/' }, shouldSyncValue: true }); const previous = { focused: false, url: 'https://new.example/' }; assert.deepEqual(resolveMobileBrowserAddressSync(previous, { focused: false, url: 'https://new.example/' }), { nextState: previous, shouldSyncValue: false }); console.log('mobile-browser-address-sync ok')"`; AST Effect count 921 -> 920. Mobile Vitest blocked by missing `expo/tsconfig.base` in this checkout. | -| #3230 | `nwparker/react-perf-mobile-browser-refs` | Mobile browser ref mirrors move out of Effects | Medium | Open | `pnpm exec oxlint mobile/src/browser/MobileBrowserPane.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 921 -> 917. | -| #3234 | `nwparker/react-perf-mobile-dictation-refs` | Mobile dictation ref mirrors move out of Effects | Medium | Open | `pnpm exec oxlint mobile/src/hooks/use-mobile-dictation.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 921 -> 920. | -| #3238 | `nwparker/react-perf-new-worktree-agent` | Mobile new-worktree agent selection resolves before commit | Medium | Open | `pnpm exec oxlint mobile/src/components/NewWorktreeModal.tsx mobile/src/components/new-worktree-agent-selection.ts mobile/src/components/new-worktree-agent-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; direct `pnpm exec tsx -e` assertions for new-worktree agent selection; AST Effect count 921 -> 919. Mobile Vitest and package-local typecheck blocked by missing mobile deps/config in this checkout. | -| #3240 | `nwparker/react-perf-pair-confirm-route` | Mobile pair-confirm route state resolves before commit | Medium | Open | `pnpm exec oxlint mobile/app/pair-confirm.tsx mobile/src/transport/pair-confirm-state.ts mobile/src/transport/pair-confirm-state.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; direct `pnpm exec tsx -e` assertions for pair-confirm route state; AST Effect count 921 -> 920. Mobile Vitest blocked by missing mobile deps/config in this checkout. | -| #3242 | `nwparker/react-perf-host-new-worktree-action` | Mobile host new-worktree route action resolves before commit | Medium | Open | `pnpm exec oxlint mobile/app/h/[hostId]/index.tsx mobile/src/host-route-action-state.ts mobile/src/host-route-action-state.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; direct `pnpm exec tsx -e` assertions for host route action state; AST Effect count 921 -> 920. Mobile Vitest blocked by missing mobile deps/config in this checkout. | -| #3244 | `nwparker/react-perf-mobile-accounts-client-ref` | Mobile Accounts unused client ref mirror removed | Low | Merged | `pnpm exec oxlint mobile/app/h/[hostId]/accounts.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 921 -> 920. | -| #3247 | `nwparker/react-perf-mobile-home-client-ref` | Mobile home shared-client ref mirror moves out of an Effect | Medium | Open | `pnpm exec oxlint mobile/app/index.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 919. | -| #3250 | `nwparker/react-perf-mobile-session-refs` | Mobile session terminal/tab ref mirrors move out of Effects | High | Open | `pnpm exec oxlint mobile/app/h/[hostId]/session/[worktreeId].tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 915. Mobile Vitest blocked by missing `expo/tsconfig.base` in this checkout. | -| #3253 | `nwparker/react-perf-mobile-tasks-client-ref` | Mobile Tasks client ref mirror moves out of an Effect | High | Open | `pnpm exec oxlint mobile/app/h/[hostId]/tasks.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 919. | -| #3255 | `nwparker/react-perf-github-assignee-reset` | GitHub assignee edit guard resets before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/components/GitHubItemDialog.tsx src/renderer/src/components/PullRequestPage.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 918. | -| #3257 | `nwparker/react-perf-diff-viewer-autoscroll-ref` | DiffViewer first-diff auto-scroll reset moves out of a layout Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/DiffViewer.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 919. | -| #3259 | `nwparker/react-perf-github-review-requests-sync` | GitHub reviewer request local-state sync moves out of Effects | Medium | Open | `pnpm exec oxlint src/renderer/src/components/GitHubItemDialog.tsx src/renderer/src/components/PullRequestPage.tsx src/renderer/src/components/TaskPage.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 917. | -| #3261 | `nwparker/react-perf-mobile-task-agent-selection` | Mobile Tasks workspace-create agent selection resolves before commit | Medium | Open | `pnpm exec oxlint mobile/app/h/[hostId]/tasks.tsx mobile/src/tasks/workspace-agent-selection.ts mobile/src/tasks/workspace-agent-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; direct `pnpm exec tsx -e` assertions for workspace-agent selection; AST Effect count 920 -> 918. Mobile Vitest blocked by missing `expo/tsconfig.base` in this checkout. | -| #3267 | `nwparker/react-perf-sidebar-ssh-reconnect-prompt` | Sidebar SSH reconnect prompts resolve before commit | High | Open | `pnpm exec oxlint src/renderer/src/components/sidebar/WorktreeCard.tsx src/renderer/src/components/sidebar/WorktreeList.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 918. | -| #3269 | `nwparker/react-perf-floating-terminal-bounds` | Floating workspace first-open bounds normalize before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 919. | -| #3271 | `nwparker/react-perf-dead-terminal-shell` | Unused terminal shell/shortcut/hook modules removed | Low | Merged | `pnpm exec oxlint src/renderer/src/components/terminal`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 920 -> 916. | -| #3273 | `nwparker/react-perf-dead-notifications-page` | Unused feature-wall orchestration notifications page removed | Low | Merged | `pnpm exec oxlint src/renderer/src/components/feature-wall/agents-orchestration src/renderer/src/components/feature-wall/AgentsOrchestrationVisual.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 916 -> 914. | -| #3275 | `nwparker/react-perf-browser-find-debounce` | BrowserFind folds debounced query state into the webview search Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/browser-pane/BrowserFind.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 913. | -| #3277 | `nwparker/react-perf-editor-copy-path-timer` | Editor path-copy toast timeout moves into the copy handler | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/EditorPanel.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/EditorPanel.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 913. | -| #3281 | `nwparker/react-perf-feature-wall-skill-state` | Feature-wall setup-card installed-state notifications move out of child Effects | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/hooks/useInstalledAgentSkills.ts src/renderer/src/components/settings/OrchestrationSetupCard.tsx src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx src/renderer/src/components/feature-wall/FeatureWallBody.tsx src/renderer/src/components/feature-wall/FeatureWallTourPanel.tsx src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx`; `pnpm exec oxlint src/renderer/src/hooks/useInstalledAgentSkills.ts src/renderer/src/components/settings/OrchestrationSetupCard.tsx src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx src/renderer/src/components/feature-wall/FeatureWallBody.tsx src/renderer/src/components/feature-wall/FeatureWallTourPanel.tsx src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 912. | -| #3283 | `nwparker/react-perf-feature-wall-session-depth` | Feature-wall session-depth open reset and publish Effects collapse into one Effect | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/feature-wall/use-feature-wall-session-depth.ts`; `pnpm exec oxlint src/renderer/src/components/feature-wall/use-feature-wall-session-depth.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 913. | -| #3285 | `nwparker/react-perf-github-link-copy-timer` | GitHub issue/PR link-copy feedback timers move into copy handlers | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/GitHubItemDialog.tsx src/renderer/src/components/PullRequestPage.tsx`; `pnpm exec oxlint src/renderer/src/components/GitHubItemDialog.tsx src/renderer/src/components/PullRequestPage.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 912. | -| #3287 | `nwparker/react-perf-shared-reduced-motion-hook` | Reduced-motion media-query subscription shared across renderer surfaces | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/hooks/usePrefersReducedMotion.ts src/renderer/src/components/feature-wall/feature-wall-modal-helpers.ts src/renderer/src/components/UpdateCard.tsx src/renderer/src/components/pet/PetOverlay.tsx`; `pnpm exec oxlint src/renderer/src/hooks/usePrefersReducedMotion.ts src/renderer/src/components/feature-wall/feature-wall-modal-helpers.ts src/renderer/src/components/UpdateCard.tsx src/renderer/src/components/pet/PetOverlay.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/UpdateCard.test.ts src/renderer/src/components/pet/pet-overlay-visibility.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 912. | -| #3289 | `nwparker/react-perf-pdf-find-open-reset` | PDF find open-state focus/reset moves out of a passive Effect | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/PdfFind.tsx src/renderer/src/components/editor/PdfViewer.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/PdfFind.tsx src/renderer/src/components/editor/PdfViewer.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 913. | -| #3292 | `nwparker/react-perf-task-gitlab-filter` | GitLab task filter validity resolves before commit | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/TaskPage.tsx`; `pnpm exec oxlint src/renderer/src/components/TaskPage.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 913. | -| #3294 | `nwparker/react-perf-nested-repo-indeterminate` | Nested repo select-all indeterminate checkbox state moves to a callback ref | Low | Merged | `pnpm exec oxfmt --write src/renderer/src/components/repo/NestedRepoTreePreview.tsx`; `pnpm exec oxlint src/renderer/src/components/repo/NestedRepoTreePreview.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 913. | -| #3296 | `nwparker/react-perf-worktree-title-focus-ref` | Workspace title rename input focus moves to callback ref | Low | Merged | `pnpm exec oxfmt --write src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx`; `pnpm exec oxlint src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/WorktreeTitleInlineRename.test.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 913 -> 912. | -| #3298 | `nwparker/react-perf-worktree-title-editing-change` | Workspace title rename editing notifications move to handlers | Low | Merged | `pnpm exec oxfmt --write src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx`; `pnpm exec oxlint src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/WorktreeTitleInlineRename.test.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | -| #3300 | `nwparker/react-perf-worktree-meta-textarea-resize` | Worktree metadata notes textarea resizes from ref and input handlers | Low | Merged | `pnpm exec oxfmt --write src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx`; `pnpm exec oxlint src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | -| #3303 | `nwparker/react-perf-editor-tab-rename-focus-ref` | Editor file-tab rename input focus moves to callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/tab-bar/EditorFileTab.tsx src/renderer/src/components/tab-bar/EditorFileTab.test.tsx`; `pnpm exec oxlint src/renderer/src/components/tab-bar/EditorFileTab.tsx src/renderer/src/components/tab-bar/EditorFileTab.test.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/tab-bar/EditorFileTab.test.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 910 -> 909. | -| #3311 | `nwparker/react-perf-tab-entry-selection-reset` | Tab entry selected action resets during render | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx`; `pnpm exec oxlint src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | -| #3314 | `nwparker/react-perf-terminal-tab-rename-focus-ref` | Terminal tab rename input focus moves to callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/tab-bar/SortableTab.tsx`; `pnpm exec oxlint src/renderer/src/components/tab-bar/SortableTab.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | -| #3316 | `nwparker/react-perf-link-bubble-focus-ref` | Rich markdown link bubble input focus moves to callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/rich-markdown-key-handler.test.ts src/renderer/src/components/editor/markdown-internal-links.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | -| #3318 | `nwparker/react-perf-image-viewer-preview-src` | Image viewer preview source derives during render | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/ImageViewer.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/ImageViewer.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/markdown-internal-links.test.ts src/renderer/src/components/editor/markdown-preview-links.test.ts src/renderer/src/components/editor/markdown-preview-url-transform.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | -| #3322 | `nwparker/react-perf-markdown-preview-search-focus-ref` | Markdown preview search input focus moves to callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/markdown-internal-links.test.ts src/renderer/src/components/editor/markdown-preview-links.test.ts src/renderer/src/components/editor/markdown-preview-url-transform.test.ts src/renderer/src/components/editor/markdown-doc-links.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | -| #3326 | `nwparker/react-perf-rich-markdown-menu-selection-render` | Rich markdown slash/doc-link menu selection derives during render | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/RichMarkdownEditor.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/rich-markdown-key-handler.test.ts src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx src/renderer/src/components/editor/markdown-doc-completions.test.ts src/renderer/src/components/editor/rich-markdown-commands.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 903. | -| #3328 | `nwparker/react-perf-editor-header-rename-focus-ref` | Editor header inline rename input focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/editor-header-file-rename.ts src/renderer/src/components/editor/EditorPanelHeader.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | -| #3330 | `nwparker/react-perf-file-search-focus-ref` | File search input focus moves to callback ref | Low | Merged | `pnpm exec oxlint src/renderer/src/components/right-sidebar/Search.tsx src/renderer/src/components/right-sidebar/SearchHeader.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/editor.test.ts src/renderer/src/lib/file-search-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | -| #3333 | `nwparker/react-perf-diff-comment-popover-focus-ref` | Diff-comment popover textarea focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/diff-comments/DiffCommentPopover.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | -| #3338 | `nwparker/react-perf-markdown-annotation-focus-ref` | Markdown annotation composer textarea focus moves to callback ref | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/MarkdownPreview.test.ts src/renderer/src/components/editor/markdown-preview-links.test.ts src/renderer/src/components/editor/markdown-preview-search.test.ts src/renderer/src/components/editor/markdown-preview-controls.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | -| #3340 | `nwparker/react-perf-status-bar-account-usage-fetch` | Status-bar inactive Claude account usage fetch moves to the expand handler | Medium | Open | `pnpm exec oxlint src/renderer/src/components/status-bar/StatusBar.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/status-bar/status-bar-context-menu-policy.test.ts src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | -| #3344 | `nwparker/react-perf-terminal-search-focus-ref` | Terminal search input focus moves to callback ref while close cleanup stays in the search Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/TerminalSearch.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | -| #3347 | `nwparker/react-perf-file-explorer-inline-focus-ref` | File Explorer inline create/rename input focus moves to a keyed callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/right-sidebar/FileExplorerRow.tsx`; `pnpm exec oxlint src/renderer/src/components/right-sidebar/FileExplorerRow.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/FileExplorer.test.tsx src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | -| #3349 | `nwparker/react-perf-font-autocomplete-scroll-ref` | Settings font autocomplete highlighted-option scroll moves to the option ref callback | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/settings/SettingsFormControls.tsx`; `pnpm exec oxlint src/renderer/src/components/settings/SettingsFormControls.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx src/renderer/src/components/settings/TerminalPane.pwsh.test.ts src/renderer/src/components/settings/TerminalPane.ghostty.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | -| #3351 | `nwparker/react-perf-ssh-passphrase-focus-ref` | SSH credential dialog input focus moves to callback ref | High | Open | `pnpm exec oxfmt --write src/renderer/src/components/settings/SshPassphraseDialog.tsx`; `pnpm exec oxlint src/renderer/src/components/settings/SshPassphraseDialog.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/ssh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | -| #3354 | `nwparker/react-perf-diff-comment-card-resize` | Diff comment card close-after-edit resize moves to save/cancel handlers | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/diff-comments/DiffCommentCard.tsx`; `pnpm exec oxlint src/renderer/src/components/diff-comments/DiffCommentCard.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | -| #3356 | `nwparker/react-perf-contextual-copy-timer-dispose` | Contextual-copy toast timeout cleanup moves to Monaco editor disposal | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/useContextualCopySetup.tsx src/renderer/src/components/editor/setup-contextual-copy.ts`; `pnpm exec oxlint src/renderer/src/components/editor/useContextualCopySetup.tsx src/renderer/src/components/editor/setup-contextual-copy.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/selection-copy.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | -| #3361 | `nwparker/react-perf-space-page-interaction` | Space page workspace-cleanup interaction records in `openSpacePage` | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx src/renderer/src/store/slices/ui.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm exec oxlint src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx src/renderer/src/store/slices/ui.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/ui.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | -| #3365 | `nwparker/react-perf-workspace-board-open-interaction` | Workspace board feature interaction records in open handlers | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/sidebar/SidebarHeader.tsx src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx`; `pnpm exec oxlint src/renderer/src/components/sidebar/SidebarHeader.tsx src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | -| #3367 | `nwparker/react-perf-activity-terminal-portals-store` | Activity terminal portal targets use `useSyncExternalStore` | High | Open | `pnpm exec oxfmt --write src/renderer/src/components/activity/activity-terminal-portal.ts`; `pnpm exec oxlint src/renderer/src/components/activity/activity-terminal-portal.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/activity/ActivityPrototypePage.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | -| #3374 | `nwparker/react-perf-project-column-preferences` | GitHub Project column preferences resolve before paint | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/github-project/ProjectViewList.tsx`; `pnpm exec oxlint src/renderer/src/components/github-project/ProjectViewList.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/github-project/project-row-filtering.test.ts src/renderer/src/components/github-project/group-sort.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 912. | - -## Reproduction Commands - -Run from the worktree root. - -```bash -rg --files -g '*.tsx' -g '*.ts' | wc -l -rg -l "\\b(useEffect|useLayoutEffect|React\\.useEffect|React\\.useLayoutEffect)\\b" -g '*.tsx' -g '*.ts' | sort -``` - -The exact Effect-site counts above came from a TypeScript AST scan so comment-only mentions are not counted. From bd6abeb58372a3cc97ed711f85c961943e51ef20 Mon Sep 17 00:00:00 2001 From: Borja <3930245+BorjaLL@users.noreply.github.com> Date: Sat, 30 May 2026 00:53:14 +0100 Subject: [PATCH 031/224] fix(session): broaden hydration carve-out beyond SSH repos (#1158) (#2641) * fix(session): broaden hydration carve-out beyond SSH repos (#1158) Finish the third deferred follow-up from #1158: preserve persisted terminal tabs when a repo is still known but its worktree list has not loaded yet. Startup fetches repos and worktrees before hydrating the saved session. If a local repo fetch degrades to an empty list, or an SSH repo has not reconnected yet, the old SSH-only carve-out could drop every tab for that repo and let the session writer persist the loss after hydration succeeded. Hydration now preserves missing session worktree IDs only when their owning repo is still in s.repos and that repo has not produced a non-empty worktree list. Once a repo has loaded one or more worktrees, that list stays authoritative so genuinely deleted worktrees are still filtered instead of being kept as zombies. The sidebar placeholder synthesis remains SSH-only: local worktree metadata should come from the next successful fetch rather than a synthetic entry. Tests cover preserving tabs for a known repo with an unloaded worktree list, dropping tabs for an unknown repo, and the existing invalid-worktree path now also pins the loaded-repo/deleted-worktree case. Co-Authored-By: Claude Opus 4.7 (1M context) * Fix authoritative hydration cleanup Co-authored-by: Orca --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../slices/store-session-cascades.test.ts | 154 ++++++++++++++++++ src/renderer/src/store/slices/terminals.ts | 32 ++-- .../src/store/slices/worktrees.test.ts | 49 ++++++ src/renderer/src/store/slices/worktrees.ts | 22 ++- 4 files changed, 244 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index ed976943d5a..cc6bd4bdde2 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -5,6 +5,7 @@ import type { AppState } from '../types' import type * as AgentStatusModule from '@/lib/agent-status' import type { BrowserTab, + DetectedWorktreeListResult, TerminalLayoutSnapshot, TerminalTab, Worktree @@ -212,6 +213,24 @@ function makeLayout(): TerminalLayoutSnapshot { return { root: null, activeLeafId: null, expandedLeafId: null } } +function makeDetectedWorktreeResult( + repoId: string, + worktrees: Worktree[], + authoritative = true +): DetectedWorktreeListResult { + return { + repoId, + authoritative, + source: authoritative ? 'git' : 'metadata-fallback', + worktrees: worktrees.map((worktree) => ({ + ...worktree, + ownership: 'orca-managed', + selectedCheckout: false, + visible: true + })) + } +} + function makeBrowserTab( overrides: Partial & { id: string; worktreeId: string; url: string } ): BrowserTab { @@ -395,6 +414,141 @@ describe('hydrateWorkspaceSession', () => { expect(s.workspaceSessionReady).toBe(false) }) + it('preserves tabs for a known repo whose worktrees have not loaded yet', () => { + // Why (#1158): empty per-repo worktrees can mean a degraded local fetch or + // SSH reconnect race, not that every persisted tab for the repo is stale. + const store = createTestStore() + const stalledWt = 'repo1::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { repo1: [] } + }) + + store.getState().hydrateWorkspaceSession({ + activeRepoId: 'repo1', + activeWorktreeId: stalledWt, + activeTabId: 'tab1', + tabsByWorktree: { + [stalledWt]: [makeTab({ id: 'tab1', worktreeId: stalledWt })] + }, + terminalLayoutsByTabId: { + tab1: makeLayout() + } + }) + + const s = store.getState() + expect(s.tabsByWorktree[stalledWt]).toHaveLength(1) + expect(s.tabsByWorktree[stalledWt][0].id).toBe('tab1') + expect(s.terminalLayoutsByTabId['tab1']).toBeDefined() + expect(s.activeWorktreeId).toBe(stalledWt) + expect(s.activeTabId).toBe('tab1') + }) + + it('preserves tabs for a known repo after a non-authoritative worktree fetch', () => { + // Why (#1158): metadata fallback means the runtime did not prove deletion. + const store = createTestStore() + const stalledWt = 'repo1::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { repo1: [] }, + detectedWorktreesByRepo: { + repo1: makeDetectedWorktreeResult('repo1', [], false) + } + }) + + store.getState().hydrateWorkspaceSession({ + activeRepoId: 'repo1', + activeWorktreeId: stalledWt, + activeTabId: 'tab1', + tabsByWorktree: { + [stalledWt]: [makeTab({ id: 'tab1', worktreeId: stalledWt })] + }, + terminalLayoutsByTabId: { + tab1: makeLayout() + } + }) + + const s = store.getState() + expect(s.tabsByWorktree[stalledWt]).toHaveLength(1) + expect(s.terminalLayoutsByTabId['tab1']).toBeDefined() + expect(s.activeWorktreeId).toBe(stalledWt) + expect(s.activeTabId).toBe('tab1') + }) + + it('drops tabs when an authoritative scan reports no matching worktrees', () => { + // Why: once git has answered authoritatively, an empty repo list means + // deleted local worktrees, not a startup race. + const store = createTestStore() + const staleWt = 'repo1::/path/deleted' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { repo1: [] }, + detectedWorktreesByRepo: { + repo1: makeDetectedWorktreeResult('repo1', []) + } + }) + + store.getState().hydrateWorkspaceSession({ + activeRepoId: 'repo1', + activeWorktreeId: staleWt, + activeTabId: 'tab-stale', + tabsByWorktree: { + [staleWt]: [makeTab({ id: 'tab-stale', worktreeId: staleWt })] + }, + terminalLayoutsByTabId: { + 'tab-stale': makeLayout() + } + }) + + const s = store.getState() + expect(s.tabsByWorktree[staleWt]).toBeUndefined() + expect(s.terminalLayoutsByTabId['tab-stale']).toBeUndefined() + expect(s.activeWorktreeId).toBeNull() + expect(s.activeTabId).toBeNull() + }) + + it('drops tabs for an unknown repo', () => { + // Why: the carve-out only forgives missing worktrees for repos that still + // exist in the repos list. If the user removed the whole repo, its tabs + // are genuinely stale and must be dropped. + const store = createTestStore() + const orphanWt = 'repoGone::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { repo1: [] } + }) + + store.getState().hydrateWorkspaceSession({ + activeRepoId: 'repo1', + activeWorktreeId: orphanWt, + activeTabId: 'tab-orphan', + tabsByWorktree: { + [orphanWt]: [makeTab({ id: 'tab-orphan', worktreeId: orphanWt })] + }, + terminalLayoutsByTabId: { + 'tab-orphan': makeLayout() + } + }) + + const s = store.getState() + expect(s.tabsByWorktree[orphanWt]).toBeUndefined() + expect(s.terminalLayoutsByTabId['tab-orphan']).toBeUndefined() + expect(s.activeWorktreeId).toBeNull() + expect(s.activeTabId).toBeNull() + }) + it('restores valid activeWorktreeId and activeTabId', () => { const store = createTestStore() const validWt = 'repo1::/path/wt1' diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index e085ba75294..2f4d7cb1899 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1774,16 +1774,17 @@ export const createTerminalSlice: StateCreator .flat() .map((worktree) => worktree.id) ) - // Why: SSH repos' worktrees are discovered at runtime via the relay, not - // persisted in orca-data.json. On restart, worktreesByRepo for SSH repos - // may still be empty when hydration runs (the SSH connection and worktree - // fetch race with session hydration). Accept session worktree IDs whose - // SSH-backed repo exists in the repos list — they will be populated once - // SSH reconnects. Without this, tabs for SSH worktrees are silently - // dropped during hydration and the session save overwrites the good data. - // Only SSH repos need this: local worktrees are persisted and a missing - // local worktree genuinely means it was deleted. - const sshRepoIds = new Set(s.repos.filter((r) => r.connectionId).map((r) => r.id)) + const knownRepoIds = new Set(s.repos.map((r) => r.id)) + const repoIdsWithLoadedWorktrees = new Set( + Object.entries(s.worktreesByRepo) + .filter(([, worktrees]) => worktrees.length > 0) + .map(([repoId]) => repoId) + ) + const repoIdsWithAuthoritativeDetectedWorktrees = new Set( + Object.entries(s.detectedWorktreesByRepo) + .filter(([, detected]) => detected.authoritative) + .map(([repoId]) => repoId) + ) // Why: the Floating Workspace is intentionally not a repo worktree, but // its tabs still use the normal terminal session pipeline so daemon PTYs // can survive app restart just like workspace terminals. @@ -1791,7 +1792,13 @@ export const createTerminalSlice: StateCreator for (const worktreeId of Object.keys(session.tabsByWorktree)) { if (!validWorktreeIds.has(worktreeId)) { const repoId = getRepoIdFromWorktreeId(worktreeId) - if (sshRepoIds.has(repoId)) { + // Why (#1158): an empty/missing list can mean degraded hydration; a + // non-empty repo list is authoritative for deleted-worktree cleanup. + if ( + knownRepoIds.has(repoId) && + !repoIdsWithLoadedWorktrees.has(repoId) && + !repoIdsWithAuthoritativeDetectedWorktrees.has(repoId) + ) { validWorktreeIds.add(worktreeId) } } @@ -1941,6 +1948,9 @@ export const createTerminalSlice: StateCreator // placeholder entries from the session's tabsByWorktree so the sidebar // shows them immediately. The placeholders will be replaced with full // data once SSH reconnects and fetchWorktrees runs. + // Why: only SSH needs placeholders; local metadata should come from the + // next successful fetch so the sidebar does not render synthetic entries. + const sshRepoIds = new Set(s.repos.filter((r) => r.connectionId).map((r) => r.id)) const worktreesByRepo = { ...s.worktreesByRepo } for (const worktreeId of Object.keys(tabsByWorktree)) { const repoId = getRepoIdFromWorktreeId(worktreeId) diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 57858d55b51..5872a1c73c7 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -414,6 +414,55 @@ describe('fetchWorktrees', () => { expect(store.getState().sortEpoch).toBe(7) }) + it('purges session-only tab keys after an authoritative refresh', async () => { + const store = createTestStore() + const deleted = makeWorktree({ + id: 'repo1::/path/deleted', + repoId: 'repo1', + path: '/path/deleted' + }) + const surviving = makeWorktree({ + id: 'repo1::/path/surviving', + repoId: 'repo1', + path: '/path/surviving' + }) + + mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('repo1', [surviving])) + store.setState({ + worktreesByRepo: { repo1: [] }, + detectedWorktreesByRepo: { + repo1: makeDetectedResult('repo1', [], { + authoritative: false, + source: 'metadata-fallback' + }) + }, + tabsByWorktree: { + [deleted.id]: [{ id: 'tab-deleted', worktreeId: deleted.id }], + [surviving.id]: [{ id: 'tab-surviving', worktreeId: surviving.id }] + }, + terminalLayoutsByTabId: { + 'tab-deleted': { root: null, activeLeafId: null, expandedLeafId: null }, + 'tab-surviving': { root: null, activeLeafId: null, expandedLeafId: null } + }, + activeWorktreeId: deleted.id, + activeTabId: 'tab-deleted', + sortEpoch: 7 + } as unknown as Partial) + + await store.getState().fetchWorktrees('repo1') + + expect(store.getState().worktreesByRepo.repo1).toEqual([surviving]) + expect(store.getState().tabsByWorktree).toEqual({ + [surviving.id]: [{ id: 'tab-surviving', worktreeId: surviving.id }] + }) + expect(store.getState().terminalLayoutsByTabId).toEqual({ + 'tab-surviving': { root: null, activeLeafId: null, expandedLeafId: null } + }) + expect(store.getState().activeWorktreeId).toBeNull() + expect(store.getState().activeTabId).toBeNull() + expect(store.getState().sortEpoch).toBe(8) + }) + it('does not purge remembered state from a non-authoritative partial refresh', async () => { const store = createTestStore() const missingFromFallback = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 430030af317..ce82c0a8a3b 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -239,12 +239,30 @@ function toVisibleWorktrees(result: DetectedWorktreeListResult): Worktree[] { return result.worktrees.filter((worktree) => worktree.visible).map(toVisibleWorktree) } +function getHydratedSessionWorktreeIdsForRepo(state: AppState, repoId: string): string[] { + return Object.keys(state.tabsByWorktree).filter((id) => getRepoIdFromWorktreeId(id) === repoId) +} + function getKnownWorktreeIdsForPurge(state: AppState, repoId: string): string[] { const detected = state.detectedWorktreesByRepo[repoId] + const knownIds = new Set() if (detected?.authoritative === true) { - return detected.worktrees.map((worktree) => worktree.id) + for (const worktree of detected.worktrees) { + knownIds.add(worktree.id) + } + } else { + for (const worktree of state.worktreesByRepo[repoId] ?? []) { + knownIds.add(worktree.id) + } } - return (state.worktreesByRepo[repoId] ?? []).map((worktree) => worktree.id) + if (!state.hasHydratedWorktreePurge) { + // Why (#1158): hydration can preserve tab keys before worktree metadata exists; + // the first authoritative scan still needs to reap deleted session-only keys. + for (const id of getHydratedSessionWorktreeIdsForRepo(state, repoId)) { + knownIds.add(id) + } + } + return [...knownIds] } function getRemovedWorktreeIdsAfterAuthoritativeScan( From 80a2b6195b497cb6ee93778baf5365de095a6ff8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 16:53:21 -0700 Subject: [PATCH 032/224] docs: remove local memory leak audit tracker (#3381) --- .../reference/memory-leak-audit-final-comb.md | 79 ------------------- 1 file changed, 79 deletions(-) delete mode 100644 docs/reference/memory-leak-audit-final-comb.md diff --git a/docs/reference/memory-leak-audit-final-comb.md b/docs/reference/memory-leak-audit-final-comb.md deleted file mode 100644 index 6aa1dcc61a1..00000000000 --- a/docs/reference/memory-leak-audit-final-comb.md +++ /dev/null @@ -1,79 +0,0 @@ -# Memory Leak Audit Final Comb - -Started: 2026-05-29 PDT - -Current tracker branch: `nwparker/mem-leak-final-comb-current`, based on `origin/main` at `a99c376f3b` after rebasing over later mainline changes. - -Objective: run one more complete pass across the current Orca tree for missed listener, timer, observer, worker, socket, watcher, subscription, abort, and disposable leaks; open scoped PRs for confirmed stranglers; include risk level and merge low-risk PRs. - -## Codebase Inventory - -Tracked code files, from `git ls-files`: - -| Bucket | Files | Status | -| ----------------- | ----: | ------- | -| `src/renderer` | 1592 | Checked | -| `src/main` | 867 | Checked | -| `src/shared` | 246 | Checked | -| `mobile/src` | 100 | Checked | -| `tests/e2e` | 72 | Checked | -| `src/relay` | 65 | Checked | -| `src/cli` | 65 | Checked | -| `config/scripts` | 33 | Checked | -| `mobile/app` | 16 | Checked | -| `other-code` | 5 | Checked | -| `src/preload` | 6 | Checked | -| `mobile/packages` | 5 | Checked | -| Total | 3072 | Checked | - -## Scan Log - -- 2026-05-29: Created fresh final-comb branch from current `origin/main`. -- 2026-05-29: Fast-forwarded the final-comb branch as `origin/main` moved; final tracker is based on `869661cbba`. -- 2026-05-29: Counted 3341 broad risk-pattern hits across `src`, `mobile`, `tests`, `config`, and `native` on the final post-fix tree. -- 2026-05-29: Re-ran heuristic buckets after fixes: - - `addEventListener` without same-file `removeEventListener`: 37 remaining. Reviewed as React Native subscriptions with `.remove()`, injected WebView document-lifetime scripts, `{ once: true }` image load handlers, IPC abort-signal test listeners, singleton image-cache invalidation, owned pane-divider DOM, and test fixture listeners. - - `setInterval` without `clearInterval`: 1 remaining, a test comment string. - - `setTimeout` without same-file `clearTimeout`: 233 remaining. Reviewed remaining main-process hits as sleep helpers, intentional app relaunch/exit delays, socket idle timeout ownership, usage-scanner yield points, and startup force-exit behavior. Confirmed final-comb timer findings are fixed in PRs listed below. - - Observers without `disconnect`: 0. - - Workers without `terminate`: 0. - - Abort controllers without `abort`: 1 test-only controller passed through the abortable API under test. - - Watchers without close/unwatch: no production misses; remaining heuristic hits are provider type/comment references. -- 2026-05-29: Re-ran React effect scan and spot-checked candidates. Remaining hits had cleanup returns (`clearTimeout`, `clearInterval`, `removeEventListener`, IPC unsubscribe) or were short one-shot UI focus/open delays. -- 2026-05-29: Rebased the final comb over current `origin/main` at `a99c376f3b` after additional mainline merges. -- 2026-05-29: Counted 3345 broad risk-pattern hits across `src`, `mobile`, `tests`, `config`, and `native` on the current tree. -- 2026-05-29: Re-ran heuristic buckets on the current tree: - - `addEventListener` without same-file `removeEventListener`: 37 remaining, same reviewed categories as the previous final-comb pass. - - `setInterval` without `clearInterval`: 1 remaining, a test comment string. - - `setTimeout` without same-file `clearTimeout`: 235 remaining. New changed-file production hits were intentional app relaunch/restart delays before process exit and a zero-delay floating-terminal focus handoff. Other changed timer candidates had explicit cleanup. - - Observers without `disconnect`: 0. - - Workers without `terminate`: 0. - - Abort controllers without `abort`: 1 test-only controller passed through the abortable API under test. - - Watchers without close/unwatch: no production misses; remaining heuristic hits are provider type/comment references. -- 2026-05-29: Focus-reviewed code changed since the previous tracker merge (`ca15aeb466..a99c376f3b`), including WSL runtime/account selection, daemon PTY checkpointing, main-window/rate-limit listeners, preload IPC subscriptions, feature-tip/settings/status-bar UI, feature-wall animation effects, FileExplorer reset effects, and renderer IPC subscriptions. No new confirmed leak needed a code PR. - -## Findings - -| PR | Risk | Status | Finding | Resolution | -| -- | ---- | ------ | ------- | ---------- | -| [#3305](https://github.com/stablyai/orca/pull/3305) | LOW | Merged | Browser reload fallback timer retained the reload promise closure, webContents reference, and listeners until the 10s fallback fired even after `did-finish-load`/`did-fail-load`. | Clear the fallback timer on early settle, guard duplicate cleanup, and test with fake timers. | -| [#3306](https://github.com/stablyai/orca/pull/3306) | LOW | Merged | Native notification retention timers stayed scheduled after close/click cleanup, retaining notification closures until fallback expiry. | Clear notification fallback timers on release, detach native listeners, and cover dispatch/startup/accessibility notification paths. | -| [#3307](https://github.com/stablyai/orca/pull/3307) | LOW | Merged | Local-network permission UDP prompt kept its 1s fallback timer and socket error listener after the send callback settled first. | Clear the fallback timer, remove the error listener, and test the `developerPermissions:request` local-network path. | - -No higher-risk findings remained after the final pass, so no second higher-risk mitigation pass was needed. - -Current rebase verification found no additional confirmed findings beyond the already merged low-risk PRs above. - -## Validation - -- `pnpm vitest run --config config/vitest.config.ts src/main/browser/agent-browser-bridge.test.ts` -- `pnpm vitest run --config config/vitest.config.ts src/main/browser/agent-browser-bridge.test.ts src/main/ipc/notifications.test.ts src/main/computer/permissions.test.ts src/main/ipc/developer-permissions.test.ts` -- `pnpm vitest run --config config/vitest.config.ts src/main/ipc/notifications.test.ts src/main/computer/permissions.test.ts` -- `pnpm vitest run --config config/vitest.config.ts src/main/ipc/developer-permissions.test.ts` -- `pnpm exec oxlint src/main/browser/agent-browser-bridge.ts src/main/browser/agent-browser-bridge.test.ts src/main/ipc/notifications.ts src/main/ipc/notifications.test.ts src/main/computer/permissions.ts src/main/computer/permissions.test.ts src/main/ipc/developer-permissions.ts src/main/ipc/developer-permissions.test.ts` -- `pnpm run typecheck:node` -- Current rebase pass: - - `git ls-files 'src/**/*.ts' 'src/**/*.tsx' 'mobile/**/*.ts' 'mobile/**/*.tsx' 'tests/**/*.ts' 'tests/**/*.tsx' 'config/**/*.js' 'config/**/*.mjs' 'native/**/*.ts'` - - broad `rg` risk-pattern count across `src`, `mobile`, `tests`, `config`, and `native` - - heuristic scans for unmatched listeners, intervals, timers, observers, workers, abort controllers, watchers, React effects, changed-file subscriptions, and changed-file timers - - manual changed-file review from `ca15aeb466` to `a99c376f3b` From 7337cab2d8b3402292478ea137b6bd034541ebf6 Mon Sep 17 00:00:00 2001 From: Emad Fussi <43019044+omd0@users.noreply.github.com> Date: Sat, 30 May 2026 02:57:56 +0300 Subject: [PATCH 033/224] feat(linux): add RPM package target and rename CLI to orca-ide Adds an RPM Linux package target and renames the Linux CLI command to orca-ide to avoid shadowing GNOME Orca, while preserving macOS and Windows CLI command behavior.\n\nFollow-up hardening keeps the Linux launcher executable, removes only old Orca-managed Linux/WSL orca launchers during migration, preserves AppImage/deb artifact names, and updates package/release tests for the RPM asset. --- .github/workflows/release-cut.yml | 4 +- config/electron-builder.config.cjs | 11 +- .../scripts/electron-builder-config.test.mjs | 10 ++ .../verify-release-required-assets.mjs | 1 + .../verify-release-required-assets.test.mjs | 10 ++ resources/linux/bin/{orca => orca-ide} | 2 +- skills/orca-cli/SKILL.md | 31 ++-- skills/orchestration/SKILL.md | 4 +- src/main/cli/cli-installer.test.ts | 169 +++++++++++------- src/main/cli/cli-installer.ts | 58 +++++- src/main/cli/packaged-cli-assets.test.ts | 13 +- src/main/cli/wsl-cli-installer.test.ts | 16 +- src/main/cli/wsl-cli-installer.ts | 22 ++- src/main/cli/wsl-cli-scripts.ts | 4 +- .../FloatingTerminalOrchestrationDialog.tsx | 6 +- .../onboarding/FeatureSetupChecklist.tsx | 4 +- .../components/settings/BrowserUsePane.tsx | 8 +- .../src/components/settings/CliSection.tsx | 25 ++- .../settings/WslCliRegistration.tsx | 21 ++- .../components/settings/browser-use-search.ts | 2 +- .../src/components/settings/general-search.ts | 4 +- .../src/lib/agent-skill-cli-prerequisite.ts | 10 +- src/renderer/src/web/web-preload-api.ts | 2 +- 23 files changed, 303 insertions(+), 134 deletions(-) rename resources/linux/bin/{orca => orca-ide} (94%) mode change 100644 => 100755 diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index d115007a09d..a306d1c6278 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -548,9 +548,11 @@ jobs: # Why: `pnpm build:release` verifies the Linux computer-use provider by # importing AT-SPI bindings, which are runtime package deps but are not # present on stock GitHub Ubuntu release runners. + # Why: `rpm` is needed by electron-builder's fpm backend to produce the + # .rpm artifact. Stock Ubuntu runners do not ship it. - name: Install Linux computer-use provider dependencies if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool + run: sudo apt-get update && sudo apt-get install -y python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool rpm - name: Verify macOS signing environment if: matrix.platform == 'mac' diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 5177a7a7a6e..b4017a7f9f9 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -200,8 +200,8 @@ module.exports = { extraResources: [ relayExtraResource, { - from: 'resources/linux/bin/orca', - to: 'bin/orca' + from: 'resources/linux/bin/orca-ide', + to: 'bin/orca-ide' }, { from: 'node_modules/agent-browser/bin/agent-browser-linux-${arch}', @@ -213,7 +213,7 @@ module.exports = { }, featureWallResources ], - target: ['AppImage', 'deb'], + target: ['AppImage', 'deb', 'rpm'], maintainer: 'stablyai', category: 'Utility' }, @@ -225,6 +225,11 @@ module.exports = { artifactName: 'orca-ide_${version}_${arch}.${ext}', depends: ['python3', 'python3-gi', 'gir1.2-atspi-2.0', 'at-spi2-core', 'xdotool', 'xclip'] }, + rpm: { + packageName: 'orca-ide', + artifactName: 'orca-ide-${version}.${arch}.${ext}', + depends: ['python3', 'python3-gobject', 'at-spi2-core', 'xdotool', 'xclip'] + }, // Why: must be true so that electron-builder rebuilds native modules // (node-pty) for each target architecture when producing dual-arch macOS // builds (x64 + arm64). With npmRebuild disabled, CI on an arm64 runner diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 453e9f6325d..4e4938a3585 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -8,4 +8,14 @@ describe('electron-builder config', () => { it('uses the multi-size icon source for Linux packages', () => { expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns') }) + + it('builds RPMs without changing existing Linux artifact names', () => { + expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb', 'rpm']) + expect(electronBuilderConfig.appImage.artifactName).toBe('orca-linux.${ext}') + expect(electronBuilderConfig.deb.artifactName).toBe('orca-ide_${version}_${arch}.${ext}') + expect(electronBuilderConfig.rpm).toMatchObject({ + packageName: 'orca-ide', + artifactName: 'orca-ide-${version}.${arch}.${ext}' + }) + }) }) diff --git a/config/scripts/verify-release-required-assets.mjs b/config/scripts/verify-release-required-assets.mjs index 12dbdb53b34..5c78533b5af 100644 --- a/config/scripts/verify-release-required-assets.mjs +++ b/config/scripts/verify-release-required-assets.mjs @@ -12,6 +12,7 @@ export function getRequiredReleaseAssetNames(tag) { 'latest.yml', 'orca-linux.AppImage', `orca-ide_${version}_amd64.deb`, + `orca-ide-${version}.x86_64.rpm`, 'orca-windows-setup.exe', 'orca-windows-setup.exe.blockmap', `Orca-${version}-mac.zip`, diff --git a/config/scripts/verify-release-required-assets.test.mjs b/config/scripts/verify-release-required-assets.test.mjs index 06a853e11ac..1caac947dc7 100644 --- a/config/scripts/verify-release-required-assets.test.mjs +++ b/config/scripts/verify-release-required-assets.test.mjs @@ -44,6 +44,16 @@ describe('getRequiredReleaseAssetNames', () => { ]) ) }) + + it('includes the Linux RPM alongside the existing AppImage and deb names', () => { + expect(getRequiredReleaseAssetNames('v1.4.27')).toEqual( + expect.arrayContaining([ + 'orca-linux.AppImage', + 'orca-ide_1.4.27_amd64.deb', + 'orca-ide-1.4.27.x86_64.rpm' + ]) + ) + }) }) describe('extractManifestAssetNames', () => { diff --git a/resources/linux/bin/orca b/resources/linux/bin/orca-ide old mode 100644 new mode 100755 similarity index 94% rename from resources/linux/bin/orca rename to resources/linux/bin/orca-ide index 1a4f8b316a5..94609a3628f --- a/resources/linux/bin/orca +++ b/resources/linux/bin/orca-ide @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Why: invoked via ~/.local/bin/orca symlink (from CliInstaller); resolve it +# Why: invoked via ~/.local/bin/orca-ide symlink (from CliInstaller); resolve it # so APP_DIR points at the real install, not the symlink's parent. SOURCE="${BASH_SOURCE[0]}" while [ -h "$SOURCE" ]; do diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index e7b5a4eafab..7e5f3a2123d 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -20,9 +20,13 @@ description: >- Use this skill when the task should go through Orca's control plane rather than directly through `git`, shell PTYs, or ad hoc filesystem access. +## Platform Note + +On Linux, the CLI command is `orca-ide` (not `orca`) to avoid conflicting with GNOME Orca, the accessibility screen reader. Everywhere this document says `orca `, Linux users should substitute `orca-ide `. macOS and Windows are unaffected. + ## When To Use -Use `orca` for: +Use `orca` (or `orca-ide` on Linux) for: - worktree orchestration inside a running Orca app - updating the current worktree comment with meaningful progress checkpoints @@ -30,7 +34,7 @@ Use `orca` for: - stopping or waiting on Orca-managed terminals - creating and managing scheduled Orca automations - accessing repos known to Orca -Do not use `orca` when plain shell tools are simpler and Orca state does not matter. +Do not use `orca` / `orca-ide` when plain shell tools are simpler and Orca state does not matter. Examples: @@ -42,27 +46,30 @@ Examples: ## Preconditions -- Prefer the public `orca` command first +- Prefer the public `orca` command first (`orca-ide` on Linux) - Orca editor/runtime should already be running, or the agent should start it with `orca open` -- Do not begin by inspecting Orca source files just to decide how to invoke the CLI. The first step is to check whether the installed `orca` command exists. +- Do not begin by inspecting Orca source files just to decide how to invoke the CLI. The first step is to check whether the installed `orca` / `orca-ide` command exists. - Do not assume a generic shell environment variable proves the agent is "inside Orca". For normal agent flows, the public CLI is the supported surface, but avoid wasting a round trip on probe-only checks when a direct Orca action would answer the question. First verify the public CLI is installed: ```bash +# macOS / Windows command -v orca +# Linux +command -v orca-ide ``` Then use the public command: ```bash -orca status --json +orca status --json # or orca-ide on Linux ``` If the task is about Orca worktrees or Orca terminals, do this before any codebase exploration: ```bash -command -v orca +command -v orca # or orca-ide on Linux orca status --json ``` @@ -72,7 +79,7 @@ If the agent truly needs to confirm that the current directory is inside an Orca orca worktree current --json ``` -If `orca` is not on PATH, say so explicitly and stop or ask the user to install/register the CLI before continuing. +If `orca` / `orca-ide` is not on PATH, say so explicitly and stop or ask the user to install/register the CLI before continuing. ## Core Workflow @@ -237,7 +244,7 @@ Why: `--direction horizontal` splits the pane **left and right** (new pane appea - Treat `orca worktree set --worktree active --comment ... --json` as a default coding-agent behavior whenever the agent reaches a meaningful checkpoint in the current Orca-managed worktree; the user does not need to explicitly ask for each update. - Update the worktree comment at significant checkpoints, not every trivial command. Good checkpoints include reproducing a bug, confirming a hypothesis, starting a risky migration, finishing a meaningful implementation slice, switching from investigation to fix, or blocking on external input. - Write comments as short status snapshots of the current state, for example `debugging AWS CLI profile resolution`, `confirmed flaky test is caused by temp-dir race`, or `fix implemented; running integration tests`. -- Prefer optimistic execution over probe-first flows for checkpoint updates: if `orca` is on `PATH`, call `orca worktree set --worktree active --comment ... --json` directly at the checkpoint instead of spending an extra cycle on `orca worktree current`. +- Prefer optimistic execution over probe-first flows for checkpoint updates: if `orca` (or `orca-ide` on Linux) is on `PATH`, call `orca worktree set --worktree active --comment ... --json` directly at the checkpoint instead of spending an extra cycle on `orca worktree current`. - If that direct update fails because Orca is unavailable or the shell is not inside an Orca-managed worktree, continue the main task and treat the comment update as best-effort unless the user explicitly made Orca state part of the task. - Use `orca worktree current --json` only when the agent actually needs the worktree identity for later logic, not as a preflight before every comment update. - Orca only injects `ORCA_WORKTREE_PATH`-style variables for some setup-hook flows, so they are not a general detection contract for agents. @@ -249,12 +256,12 @@ Why: `--direction horizontal` splits the pane **left and right** (new pane appea - Use `terminal create` to spin up new terminal tabs programmatically, optionally with a `--command` for startup (e.g. `--command "claude"` to launch Claude Code) and `--title` for labeling. In local Orca sessions, `--command "codex"` is routed through Orca's visible terminal path automatically so Codex does not start as a headless/background PTY. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching. - Use `terminal split` to create split panes within an existing terminal tab. Pass `--command` to run a command in the new pane. - Prefer Orca worktree selectors over hardcoded paths when Orca identity already exists. -- If the user asks for CLI UX feedback, test the public `orca` command first. Only inspect `src/cli` or use `node out/cli/index.js` if the public command is missing or the task is explicitly about implementation internals. -- If a command fails, prefer retrying with the public `orca` command before concluding the CLI is broken, unless the failure already came from `orca` itself. +- If the user asks for CLI UX feedback, test the public `orca` / `orca-ide` command first. Only inspect `src/cli` or use `node out/cli/index.js` if the public command is missing or the task is explicitly about implementation internals. +- If a command fails, prefer retrying with the public `orca` / `orca-ide` command before concluding the CLI is broken, unless the failure already came from the CLI itself. ## Browser Automation -The `orca` CLI also drives the built-in Orca browser. The core workflow is a **snapshot-interact-re-snapshot** loop: +The `orca` CLI (or `orca-ide` on Linux) also drives the built-in Orca browser. The core workflow is a **snapshot-interact-re-snapshot** loop: 1. **Snapshot** the page to see interactive elements and their refs. 2. **Interact** using refs (`@e1`, `@e3`, etc.) to click, fill, or select. @@ -626,7 +633,7 @@ When `orca tab create` opens a new tab, it is automatically set as the active ta - Terminal handles are ephemeral and tied to the current Orca runtime. If Orca restarts, handles change. - `terminal wait` supports `--for exit` (wait for process exit) and `--for tui-idle` (wait for a recognized agent CLI like Claude Code, Gemini, or Codex to finish its current task, detected via OSC title transitions). `tui-idle` defaults to a 5-minute timeout if `--timeout-ms` is not specified. Real coding tasks routinely take 15-60 minutes — always pass `--timeout-ms` explicitly. - Orca is the source of truth for worktree/terminal state; do not duplicate that state with manual assumptions. -- The public `orca` command is the interface users experience. Agents should validate and use that surface, not repo-local implementation entrypoints. +- The public `orca` command (`orca-ide` on Linux) is the interface users experience. Agents should validate and use that surface, not repo-local implementation entrypoints. - The default bounded `terminal read` preview is for status monitoring. For retained transcript extraction, use `terminal read --json` with `oldestCursor`/`nextCursor`, `--cursor`, and `--limit`. ## References diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index 86e682922ef..9a22023be36 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -28,7 +28,7 @@ Use `orca-cli` instead for ordinary terminal control, shell commands, browser au ## Preconditions - Orca must be running (`orca status --json` should return `runtime: true`). -- The `orca` CLI must be on PATH (installed via Settings > Browser > Enable Orca CLI). +- The `orca` CLI must be on PATH (`orca-ide` on Linux; installed via Settings > Browser > Enable Orca CLI). - The orchestration experimental feature must be enabled in Settings > Experimental. - All `orca orchestration` commands are RPC calls to the running Orca runtime — they require an active Orca session. @@ -92,7 +92,7 @@ orca orchestration dispatch --task --to [--from ] [-- orca orchestration dispatch-show --task [--json] ``` -Why: `--inject` sends a preamble that teaches the agent how to use `orca orchestration send --type worker_done` to report completion. All agents have `orca` on PATH and can execute shell commands. The preamble maximizes structured feedback but the system works without it (coordinator falls back to idle detection + output reading). +Why: `--inject` sends a preamble that teaches the agent how to use `orca orchestration send --type worker_done` to report completion. All agents have `orca` (or `orca-ide` on Linux) on PATH and can execute shell commands. The preamble maximizes structured feedback but the system works without it (coordinator falls back to idle detection + output reading). Why: `--inject` requires a recognized agent CLI (e.g. Claude Code) running in the target terminal. If the terminal is a bare shell, omit `--inject` and send the prompt manually with `terminal send`. diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index b0dc2eb2475..43f525b3f94 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'node:fs/promises' +import { lstat, mkdtemp, mkdir, readFile, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -33,60 +33,98 @@ describe('CliInstaller', () => { }) // Why: this test creates Unix symlinks and shell scripts that only apply on macOS. - it.skipIf(process.platform === 'win32')('creates a dev launcher and installs a macOS symlink in the requested path', async () => { - const fixture = await makeFixture() - const installPath = join(fixture.root, 'bin', 'orca') - const installer = new CliInstaller({ - platform: 'darwin', - isPackaged: false, - userDataPath: fixture.userDataPath, - execPath: '/Applications/Orca.app/Contents/MacOS/Orca', - appPath: fixture.appPath, - commandPathOverride: installPath, - processPathEnv: join(fixture.root, 'bin') - }) + it.skipIf(process.platform === 'win32')( + 'creates a dev launcher and installs a macOS symlink in the requested path', + async () => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'bin', 'orca') + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + appPath: fixture.appPath, + commandPathOverride: installPath, + processPathEnv: join(fixture.root, 'bin') + }) - const initial = await installer.getStatus() - expect(initial.state).toBe('not_installed') - expect(initial.launcherPath).toContain(join('userData', 'cli', 'bin', 'orca')) + const initial = await installer.getStatus() + expect(initial.state).toBe('not_installed') + expect(initial.launcherPath).toContain(join('userData', 'cli', 'bin', 'orca')) - const installed = await installer.install() - expect(installed.state).toBe('installed') - expect(installed.pathConfigured).toBe(true) + const installed = await installer.install() + expect(installed.state).toBe('installed') + expect(installed.pathConfigured).toBe(true) - const launcherContent = await readFile(installed.launcherPath as string, 'utf8') - expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1') - expect(launcherContent).toContain(join(fixture.appPath, 'out', 'cli', 'index.js')) + const launcherContent = await readFile(installed.launcherPath as string, 'utf8') + expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1') + expect(launcherContent).toContain(join(fixture.appPath, 'out', 'cli', 'index.js')) - const removed = await installer.remove() - expect(removed.state).toBe('not_installed') - }) + const removed = await installer.remove() + expect(removed.state).toBe('not_installed') + } + ) // Why: this test creates Unix symlinks and shell scripts that only apply on Linux. - it.skipIf(process.platform === 'win32')('creates a linux symlink under the requested path and warns when PATH is missing', async () => { - const fixture = await makeFixture() - const installPath = join(fixture.root, '.local', 'bin', 'orca') - const installer = new CliInstaller({ - platform: 'linux', - isPackaged: false, - userDataPath: fixture.userDataPath, - execPath: '/opt/Orca/orca', - appPath: fixture.appPath, - commandPathOverride: installPath, - processPathEnv: '/usr/bin' - }) + it.skipIf(process.platform === 'win32')( + 'creates a linux symlink under the requested path and warns when PATH is missing', + async () => { + const fixture = await makeFixture() + const installPath = join(fixture.root, '.local', 'bin', 'orca-ide') + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: '/opt/Orca/orca-ide', + appPath: fixture.appPath, + commandPathOverride: installPath, + processPathEnv: '/usr/bin' + }) - const installed = await installer.install() - expect(installed.state).toBe('installed') - expect(installed.pathConfigured).toBe(false) - expect(installed.detail).toContain('.local') + const installed = await installer.install() + expect(installed.state).toBe('installed') + expect(installed.commandName).toBe('orca-ide') + expect(installed.pathConfigured).toBe(false) + expect(installed.detail).toContain('.local') - const launcherContent = await readFile(installed.launcherPath as string, 'utf8') - expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1') + const launcherContent = await readFile(installed.launcherPath as string, 'utf8') + expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1') - const removed = await installer.remove() - expect(removed.state).toBe('not_installed') - }) + const removed = await installer.remove() + expect(removed.state).toBe('not_installed') + } + ) + + // Why: Linux renamed the public command to avoid shadowing GNOME Orca, so + // upgrading must clean up only the old symlink owned by prior Orca installs. + it.skipIf(process.platform === 'win32')( + 'removes the old managed linux orca symlink when installing orca-ide', + async () => { + const fixture = await makeFixture() + const homePath = join(fixture.root, 'home') + const commandDir = join(homePath, '.local', 'bin') + const oldLauncherPath = join(fixture.userDataPath, 'cli', 'bin', 'orca') + const legacyCommandPath = join(commandDir, 'orca') + await mkdir(commandDir, { recursive: true }) + await mkdir(join(fixture.userDataPath, 'cli', 'bin'), { recursive: true }) + await writeFile(oldLauncherPath, '#!/usr/bin/env bash\n', 'utf8') + await symlink(oldLauncherPath, legacyCommandPath) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: '/opt/Orca/orca-ide', + appPath: fixture.appPath, + homePath, + processPathEnv: commandDir + }) + + const installed = await installer.install() + expect(installed.commandPath).toBe(join(commandDir, 'orca-ide')) + await expect(lstat(legacyCommandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + } + ) it('creates a windows wrapper and updates the user PATH', async () => { const fixture = await makeFixture() @@ -120,24 +158,27 @@ describe('CliInstaller', () => { }) // Why: this test creates a Unix symlink to /tmp/not-orca, which only applies on macOS/Linux. - it.skipIf(process.platform === 'win32')('reports stale when a different symlink already exists', async () => { - const fixture = await makeFixture() - const installPath = join(fixture.root, 'bin', 'orca') - await mkdir(join(fixture.root, 'bin'), { recursive: true }) - await symlink('/tmp/not-orca', installPath) + it.skipIf(process.platform === 'win32')( + 'reports stale when a different symlink already exists', + async () => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'bin', 'orca') + await mkdir(join(fixture.root, 'bin'), { recursive: true }) + await symlink('/tmp/not-orca', installPath) - const installer = new CliInstaller({ - platform: 'darwin', - isPackaged: false, - userDataPath: fixture.userDataPath, - execPath: '/Applications/Orca.app/Contents/MacOS/Orca', - appPath: fixture.appPath, - commandPathOverride: installPath - }) + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + appPath: fixture.appPath, + commandPathOverride: installPath + }) - await expect(installer.getStatus()).resolves.toMatchObject({ - state: 'stale', - supported: true - }) - }) + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'stale', + supported: true + }) + } + ) }) diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index a3dde42ac74..054517b3742 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -10,6 +10,8 @@ import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-instal const execFileAsync = promisify(execFile) const DEFAULT_MAC_COMMAND_PATH = '/usr/local/bin/orca' +const LINUX_COMMAND_NAME = 'orca-ide' +const LEGACY_LINUX_COMMAND_NAME = 'orca' const DEV_LAUNCHER_DIR = ['cli', 'bin'] type CliInstallerOptions = { @@ -48,6 +50,11 @@ export class CliInstaller { private readonly userPathReader: () => Promise private readonly userPathWriter: (value: string) => Promise + // Why: Linux uses `orca-ide` to avoid shadowing GNOME Orca's /usr/bin/orca. + private get commandName(): string { + return this.platform === 'linux' ? LINUX_COMMAND_NAME : 'orca' + } + constructor(options: CliInstallerOptions = {}) { this.platform = options.platform ?? process.platform this.isPackaged = options.isPackaged ?? app.isPackaged @@ -73,7 +80,7 @@ export class CliInstaller { if (!spec) { return { platform: this.platform, - commandName: 'orca', + commandName: this.commandName, commandPath: null, pathDirectory: null, pathConfigured: false, @@ -91,7 +98,7 @@ export class CliInstaller { if (!launcherPath) { return { platform: this.platform, - commandName: 'orca', + commandName: this.commandName, commandPath: spec.commandPath, pathDirectory: dirname(spec.commandPath), pathConfigured: false, @@ -130,6 +137,7 @@ export class CliInstaller { // eslint-disable-next-line unicorn/prefer-ternary -- Why: the install path performs async side effects and is easier to audit as an explicit branch than as an awaited ternary. if (status.installMethod === 'symlink') { await this.installSymlink(status) + await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) } else { await this.installWindowsWrapper(status.commandPath, status.launcherPath) } @@ -150,6 +158,7 @@ export class CliInstaller { return status } if (status.state === 'not_installed') { + await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) if (this.platform === 'win32') { await this.removeWindowsPathEntry(dirname(status.commandPath)) return this.getStatus() @@ -165,6 +174,7 @@ export class CliInstaller { if (status.installMethod === 'symlink') { await this.removeSymlink(status.commandPath) + await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) } else { await unlink(status.commandPath) await this.removeWindowsPathEntry(dirname(status.commandPath)) @@ -209,7 +219,10 @@ export class CliInstaller { // Why: Linux does not have a single privileged global shell-command flow // equivalent to macOS's /usr/local/bin integration. ~/.local/bin is the // least surprising user-scoped location that many distros already expose. - return join(this.homePath, '.local', 'bin', 'orca') + // Why `orca-ide`: GNOME Orca (the screen reader) ships /usr/bin/orca on + // most Linux distros. Using `orca-ide` avoids shadowing that system + // command, matching the executableName already used for the Electron binary. + return join(this.homePath, '.local', 'bin', LINUX_COMMAND_NAME) } if (this.platform === 'win32') { @@ -275,6 +288,36 @@ export class CliInstaller { } } + private async removeLegacyLinuxCommandIfManaged(launcherPath: string | null): Promise { + if (this.platform !== 'linux' || this.commandPathOverride || !launcherPath) { + return + } + + const legacyCommandPath = join(this.homePath, '.local', 'bin', LEGACY_LINUX_COMMAND_NAME) + try { + const stats = await lstat(legacyCommandPath) + if (!stats.isSymbolicLink()) { + return + } + + const currentTarget = await readlink(legacyCommandPath) + const resolvedCurrentTarget = resolve(dirname(legacyCommandPath), currentTarget) + const legacyLauncherPath = resolve(dirname(launcherPath), LEGACY_LINUX_COMMAND_NAME) + if (resolvedCurrentTarget !== legacyLauncherPath) { + return + } + + // Why: after the Linux command rename, the old Orca-owned `orca` symlink + // would keep shadowing GNOME Orca even though the new command is installed. + await unlink(legacyCommandPath) + } catch (error) { + if (isMissingError(error)) { + return + } + throw error + } + } + private async installWindowsWrapper(commandPath: string, launcherPath: string): Promise { await writeFile(commandPath, buildWindowsForwarder(launcherPath), 'utf8') } @@ -387,7 +430,7 @@ export class CliInstaller { }): CliInstallStatus { return { platform: this.platform, - commandName: 'orca', + commandName: this.commandName, commandPath: args.commandPath, pathDirectory: dirname(args.commandPath), pathConfigured: false, @@ -480,7 +523,7 @@ async function ensureDevLauncher(args: { const launcherPath = join( args.userDataPath, ...DEV_LAUNCHER_DIR, - args.platform === 'win32' ? 'orca.cmd' : 'orca' + args.platform === 'win32' ? 'orca.cmd' : args.platform === 'linux' ? LINUX_COMMAND_NAME : 'orca' ) await mkdir(dirname(launcherPath), { recursive: true }) @@ -623,9 +666,12 @@ export function getBundledLauncherPath( platform: NodeJS.Platform, resourcesPath: string ): string | null { - if (platform === 'darwin' || platform === 'linux') { + if (platform === 'darwin') { return join(resourcesPath, 'bin', 'orca') } + if (platform === 'linux') { + return join(resourcesPath, 'bin', LINUX_COMMAND_NAME) + } if (platform === 'win32') { return join(resourcesPath, 'bin', 'orca.cmd') } diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index fcf877af8e1..b13d794f9b2 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { copyFile, chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { copyFile, chmod, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -12,7 +12,7 @@ const itRunsUnixShell = process.platform === 'win32' ? it.skip : it const builderConfig = require('../../../config/electron-builder.config.cjs') as { asarUnpack?: string[] } -const linuxLauncherAsset = new URL('../../../resources/linux/bin/orca', import.meta.url) +const linuxLauncherAsset = new URL('../../../resources/linux/bin/orca-ide', import.meta.url) describe('packaged CLI assets', () => { it('unpacks runtime dependencies used before Electron asar integration is available', () => { @@ -26,6 +26,11 @@ describe('packaged CLI assets', () => { ) }) + itRunsUnixShell('keeps the Linux launcher executable in packaged resources', async () => { + const launcherStats = await stat(linuxLauncherAsset) + expect(launcherStats.mode & 0o111).not.toBe(0) + }) + itRunsUnixShell( 'runs the Linux launcher from its packaged path and installed symlink', async () => { @@ -35,7 +40,7 @@ describe('packaged CLI assets', () => { const resourcesDir = join(appDir, 'resources') const launcherDir = join(resourcesDir, 'bin') const cliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') - const launcherPath = join(launcherDir, 'orca') + const launcherPath = join(launcherDir, 'orca-ide') const electronPath = join(appDir, 'orca-ide') const cliPath = join(cliDir, 'index.js') @@ -62,7 +67,7 @@ printf 'arg=%s\\n' "$@" const homeDir = join(root, 'home') const commandDir = join(homeDir, '.local', 'bin') - const commandPath = join(commandDir, 'orca') + const commandPath = join(commandDir, 'orca-ide') await mkdir(commandDir, { recursive: true }) await mkdir(join(homeDir, 'orca'), { recursive: true }) await symlink(launcherPath, commandPath) diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index ef51b67df2b..3804251c1ba 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -20,7 +20,7 @@ function makeHostStatus(launcherPath = 'C:\\Users\\me\\AppData\\Local\\Orca\\bin } function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin = true) { - const commandPath = '/home/alice/.local/bin/orca' + const commandPath = '/home/alice/.local/bin/orca-ide' const bridgePath = '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' const files = new Map() if (initialFile !== null) { @@ -91,7 +91,7 @@ describe('WslCliInstaller', () => { await expect(installer.getStatus()).resolves.toMatchObject({ state: 'not_installed', - commandPath: '/home/alice/.local/bin/orca' + commandPath: '/home/alice/.local/bin/orca-ide' }) const installed = await installer.install() @@ -108,6 +108,18 @@ describe('WslCliInstaller', () => { ) ) expect(wsl.getBridge()).toBe(_internals.buildWslBridgeScript()) + const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"')) + expect(installCommand).toContain("legacy_command_path='/home/alice/.local/bin/orca'") + expect(installCommand).toContain('rm -f "$legacy_command_path"') + }) + + it('derives the shared WSL bridge path for current and legacy command names', () => { + expect(_internals.getBridgePathFromCommandPath('/home/alice/.local/bin/orca-ide')).toBe( + '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' + ) + expect(_internals.getBridgePathFromCommandPath('/home/alice/.local/bin/orca')).toBe( + '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' + ) }) it('reports installed WSL launchers whose bin directory is missing from PATH', async () => { diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index 257ea5db3ae..33eedeb3e5f 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -21,6 +21,8 @@ import { const execFileAsync = promisify(execFile) const MANAGED_MARKER = getWslLauncherMarker() const BRIDGE_MANAGED_MARKER = getWslBridgeMarker() +const WSL_COMMAND_NAME = 'orca-ide' +const LEGACY_WSL_COMMAND_NAME = 'orca' type WslCliInstallerOptions = { platform?: NodeJS.Platform @@ -137,6 +139,9 @@ export class WslCliInstaller { `mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`, `command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`, `bridge_path=${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`, + `legacy_command_path=${quoteShell( + `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}` + )}`, 'bridge_tmp="${bridge_path}.tmp.$$"', 'cleanup() { rm -f "$command_tmp" "$bridge_tmp"; }', 'trap cleanup EXIT', @@ -158,6 +163,9 @@ export class WslCliInstaller { getBridgePathFromCommandPath(status.commandPath), BRIDGE_MANAGED_MARKER ), + // Why: the command was renamed to avoid GNOME Orca; remove only the + // old Orca-managed WSL wrapper so unmanaged `orca` commands survive. + `if [ -f "$legacy_command_path" ] && grep -Fq ${quoteShell(MANAGED_MARKER)} "$legacy_command_path"; then rm -f "$legacy_command_path"; fi`, `mv -f "$bridge_tmp" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`, `mv -f "$command_tmp" ${quoteShell(status.commandPath)}`, 'trap - EXIT' @@ -240,7 +248,8 @@ export class WslCliInstaller { } const pathDirectory = `${home}/.local/bin` - const commandPath = `${pathDirectory}/orca` + // Why: matches the Linux CLI rename to `orca-ide` (avoids GNOME Orca conflict). + const commandPath = `${pathDirectory}/${WSL_COMMAND_NAME}` const pathConfigured = ( await this.run( @@ -296,9 +305,9 @@ export class WslCliInstaller { }): CliInstallStatus { return { platform: 'linux', - commandName: 'orca', + commandName: WSL_COMMAND_NAME, commandPath: args.commandPath, - pathDirectory: args.commandPath.replace(/\/orca$/, ''), + pathDirectory: getPosixDirname(args.commandPath), pathConfigured: args.pathConfigured, launcherPath: args.launcherPath, installMethod: 'wrapper', @@ -308,7 +317,7 @@ export class WslCliInstaller { unsupportedReason: null, detail: args.state === 'installed' && !args.pathConfigured - ? `${args.commandPath} is registered, but ${args.commandPath.replace(/\/orca$/, '')} is not on PATH in ${args.distro}.` + ? `${args.commandPath} is registered, but ${getPosixDirname(args.commandPath)} is not on PATH in ${args.distro}.` : args.detail } } @@ -319,7 +328,7 @@ export class WslCliInstaller { ): CliInstallStatus { return { platform: 'linux', - commandName: 'orca', + commandName: WSL_COMMAND_NAME, commandPath: null, pathDirectory: null, pathConfigured: false, @@ -360,5 +369,6 @@ function buildEncodedWslBashCommand(command: string): string { export const _internals = { buildEncodedWslBashCommand, buildWslBridgeScript, - buildWslLauncher + buildWslLauncher, + getBridgePathFromCommandPath } diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 0a74e9c51fe..23f8f27ce65 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -44,7 +44,9 @@ try { } export function getBridgePathFromCommandPath(commandPath: string): string { - return `${commandPath.replace(/\/\.local\/bin\/orca$/, '/.local/share/orca')}/orca-wsl-bridge.ps1` + // Why: both the current Linux command and the legacy pre-rename command + // share one WSL bridge under ~/.local/share/orca. + return `${commandPath.replace(/\/\.local\/bin\/(?:orca|orca-ide)$/, '/.local/share/orca')}/orca-wsl-bridge.ps1` } export function buildSafeReplaceGuard(path: string, managedMarker: string): string { diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx index 7a6d4e17b5d..6e4d0044a32 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx @@ -61,10 +61,10 @@ export function FloatingTerminalOrchestrationDialog({ const cliInstalled = isOrcaCliAvailableOnPath(cliStatus) const cliSupported = cliStatus?.supported ?? false const cliLabel = cliInstalled - ? 'orca is on PATH' + ? 'Orca CLI is on PATH' : cliLoading ? 'Checking CLI status...' - : (cliStatus?.detail ?? 'Register orca so agents can call Orca from a terminal.') + : (cliStatus?.detail ?? 'Register the Orca CLI so agents can call Orca from a terminal.') const handleInstallCli = async (): Promise => { setCliBusy(true) @@ -77,7 +77,7 @@ export function FloatingTerminalOrchestrationDialog({ onSetupStateChange() } if (isOrcaCliAvailableOnPath(next)) { - toast.success('Registered `orca` in PATH.') + toast.success('Registered the Orca CLI in PATH.') } } finally { setCliBusy(false) diff --git a/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx b/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx index 88a6a0e16b7..7d07a45bd43 100644 --- a/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx +++ b/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx @@ -31,14 +31,14 @@ const FEATURE_SETUP_ROWS: readonly FeatureSetupRow[] = [ id: 'computerUse', title: 'Computer Use', description: 'Agents can inspect app windows and operate local apps when you ask.', - setupSummary: 'Registers `orca`, opens permissions, and prepares the skill.', + setupSummary: 'Registers the Orca CLI, opens permissions, and prepares the skill.', icon: }, { id: 'orchestration', title: 'Agent Orchestration', description: 'Agents can message each other, take tasks, and coordinate handoffs.', - setupSummary: 'Registers `orca`, enables orchestration, and prepares the skill.', + setupSummary: 'Registers the Orca CLI, enables orchestration, and prepares the skill.', icon: } ] diff --git a/src/renderer/src/components/settings/BrowserUsePane.tsx b/src/renderer/src/components/settings/BrowserUsePane.tsx index f2677f52958..146a6260ca3 100644 --- a/src/renderer/src/components/settings/BrowserUsePane.tsx +++ b/src/renderer/src/components/settings/BrowserUsePane.tsx @@ -124,7 +124,7 @@ export function BrowserUseSetup({ onStatusChange: setCliStatus }) if (isOrcaCliAvailableOnPath(next)) { - toast.success('Registered `orca` in PATH.') + toast.success('Registered the Orca CLI in PATH.') } } finally { setCliBusy(false) @@ -254,7 +254,7 @@ export function BrowserUseSetup({ {showStep1 ? ( @@ -266,8 +266,8 @@ export function BrowserUseSetup({

Enable Orca CLI

- Registers the orca{' '} - command so agents can orchestrate the browser from their shell. + Registers the Orca CLI command so agents can orchestrate the browser from their + shell.

{cliStatus?.commandPath && cliEnabled ? (

diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index bc332e73610..71012697b8d 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -47,7 +47,7 @@ function getInstallDescription(platform: string): string { return 'Register `orca` in /usr/local/bin.' } if (platform === 'linux') { - return 'Register `orca` in ~/.local/bin.' + return 'Register `orca-ide` in ~/.local/bin.' } if (platform === 'win32') { return 'Register `orca` in your user PATH.' @@ -55,6 +55,10 @@ function getInstallDescription(platform: string): string { return 'CLI registration is not yet available on this platform.' } +function getFallbackCommandName(platform: string): string { + return platform === 'linux' ? 'orca-ide' : 'orca' +} + export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Element { const [status, setStatus] = useState(null) const [loading, setLoading] = useState(true) @@ -88,6 +92,7 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem const isSupported = status?.supported ?? false const isBrowserManaged = status?.unsupportedReason === 'launch_mode_unavailable' const revealLabel = getRevealLabel(currentPlatform) + const commandName = status?.commandName ?? getFallbackCommandName(currentPlatform) const canRevealCommandPath = status?.commandPath != null && ['installed', 'stale', 'conflict'].includes(status.state) @@ -97,9 +102,11 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem const next = await window.api.cli.install() setStatus(next) setDialogOpen(false) - toast.success('Registered `orca` in PATH.') + toast.success(`Registered \`${next.commandName}\` in PATH.`) } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to register `orca` in PATH.') + toast.error( + error instanceof Error ? error.message : `Failed to register \`${commandName}\` in PATH.` + ) } finally { setBusyAction(null) } @@ -111,9 +118,11 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem const next = await window.api.cli.remove() setStatus(next) setDialogOpen(false) - toast.success('Removed `orca` from PATH.') + toast.success(`Removed \`${next.commandName}\` from PATH.`) } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to remove `orca` from PATH.') + toast.error( + error instanceof Error ? error.message : `Failed to remove \`${commandName}\` from PATH.` + ) } finally { setBusyAction(null) } @@ -253,12 +262,14 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem - {isEnabled ? 'Remove `orca` from PATH?' : 'Register `orca` in PATH?'} + {isEnabled + ? `Remove \`${commandName}\` from PATH?` + : `Register \`${commandName}\` in PATH?`} {isEnabled ? 'This removes the shell command symlink. Orca itself remains installed.' - : `Orca will register ${status?.commandPath ?? '`orca`'} so the command works from your terminal.`} + : `Orca will register ${status?.commandPath ?? commandName} so the command works from your terminal.`} {status?.commandPath ? ( diff --git a/src/renderer/src/components/settings/WslCliRegistration.tsx b/src/renderer/src/components/settings/WslCliRegistration.tsx index 0450c5a5ae4..c1d9f1eda47 100644 --- a/src/renderer/src/components/settings/WslCliRegistration.tsx +++ b/src/renderer/src/components/settings/WslCliRegistration.tsx @@ -52,6 +52,7 @@ export function WslCliRegistration({ const isEnabled = status?.state === 'installed' const isSupported = status?.supported ?? false + const commandName = status?.commandName ?? 'orca-ide' const handleInstall = async (): Promise => { setBusyAction('install') @@ -59,9 +60,11 @@ export function WslCliRegistration({ const next = await window.api.cli.installWsl() setStatus(next) setDialogOpen(false) - toast.success('Registered `orca` in WSL.') + toast.success(`Registered \`${next.commandName}\` in WSL.`) } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to register `orca` in WSL.') + toast.error( + error instanceof Error ? error.message : `Failed to register \`${commandName}\` in WSL.` + ) } finally { setBusyAction(null) } @@ -73,9 +76,11 @@ export function WslCliRegistration({ const next = await window.api.cli.removeWsl() setStatus(next) setDialogOpen(false) - toast.success('Removed `orca` from WSL.') + toast.success(`Removed \`${next.commandName}\` from WSL.`) } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to remove `orca` from WSL.') + toast.error( + error instanceof Error ? error.message : `Failed to remove \`${commandName}\` from WSL.` + ) } finally { setBusyAction(null) } @@ -90,7 +95,7 @@ export function WslCliRegistration({

{loading ? 'Checking WSL CLI registration...' - : (status?.detail ?? 'Register `orca` in ~/.local/bin inside WSL.')} + : (status?.detail ?? 'Register `orca-ide` in ~/.local/bin inside WSL.')}

@@ -148,12 +153,14 @@ export function WslCliRegistration({ - {isEnabled ? 'Remove `orca` from WSL?' : 'Register `orca` in WSL?'} + {isEnabled + ? `Remove \`${commandName}\` from WSL?` + : `Register \`${commandName}\` in WSL?`} {isEnabled ? 'This removes the WSL shell command. Orca itself remains installed on Windows.' - : `Orca will register ${status?.commandPath ?? '`orca`'} so the command works from WSL terminals.`} + : `Orca will register ${status?.commandPath ?? commandName} so the command works from WSL terminals.`} {status?.commandPath ? ( diff --git a/src/renderer/src/components/settings/browser-use-search.ts b/src/renderer/src/components/settings/browser-use-search.ts index 84a16c98752..6ea9489b43b 100644 --- a/src/renderer/src/components/settings/browser-use-search.ts +++ b/src/renderer/src/components/settings/browser-use-search.ts @@ -3,7 +3,7 @@ import type { SettingsSearchEntry } from './settings-search' export const BROWSER_USE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { title: 'Enable Orca CLI', - description: 'Register the orca shell command so agents can drive the browser.', + description: 'Register the Orca CLI so agents can drive the browser.', keywords: ['browser use', 'cli', 'orca', 'path', 'command', 'shell', 'enable', 'setup'] }, { diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts index 332dbd68c15..b1d12d4bda9 100644 --- a/src/renderer/src/components/settings/general-search.ts +++ b/src/renderer/src/components/settings/general-search.ts @@ -81,14 +81,14 @@ export const GENERAL_NAVIGATION_SEARCH_ENTRIES: SettingsSearchEntry[] = [ export const GENERAL_CLI_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { title: 'Orca CLI', - description: 'Register or remove the orca shell command.', + description: 'Register or remove the Orca CLI command.', keywords: ['cli', 'path', 'terminal', 'command', 'shell command'], cmdJKeywords: ['cli', 'path', 'command', 'shell command'], targetSectionId: 'cli' }, { title: 'Agent skill', - description: 'Install the Orca skill so agents know to use the orca CLI.', + description: 'Install the Orca skill so agents know to use the Orca CLI.', keywords: ['skill', 'agents', 'npx'] } ] diff --git a/src/renderer/src/lib/agent-skill-cli-prerequisite.ts b/src/renderer/src/lib/agent-skill-cli-prerequisite.ts index 5d39018c2dc..28ad6da2aa6 100644 --- a/src/renderer/src/lib/agent-skill-cli-prerequisite.ts +++ b/src/renderer/src/lib/agent-skill-cli-prerequisite.ts @@ -7,11 +7,11 @@ type EnsureOrcaCliAvailableOptions = { } export const AGENT_SKILL_CLI_PREREQUISITE_NOTICE = - 'Before opening setup, Orca may show a system prompt to register the orca command on PATH.' + 'Before opening setup, Orca may show a system prompt to register the Orca CLI command on PATH.' export const CLI_PREREQUISITE_REGISTRATION_TOAST = 'Orca needs to register its CLI on PATH.' export const CLI_PREREQUISITE_REGISTRATION_TOAST_DESCRIPTION = - 'Approve the system prompt so skill setup can use the orca command.' + 'Approve the system prompt so skill setup can use the Orca CLI command.' export function isOrcaCliAvailableOnPath(status: CliInstallStatus | null | undefined): boolean { return status?.state === 'installed' && status.pathConfigured @@ -42,7 +42,7 @@ export async function ensureOrcaCliAvailableForAgentSkillTerminal({ return status } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to register `orca` in PATH.') + toast.error(error instanceof Error ? error.message : 'Failed to register the Orca CLI in PATH.') return null } } @@ -78,8 +78,8 @@ function showCliPrerequisiteWarning(status: CliInstallStatus): void { if (!status.pathConfigured) { // Why: the skill installer opens a real shell; agents only get the expected - // Orca affordances when that shell can resolve the `orca` command. - toast.warning('`orca` is not visible on PATH yet', { + // Orca affordances when that shell can resolve the Orca CLI command. + toast.warning('Orca CLI is not visible on PATH yet', { description: status.detail ?? 'Restart your shell or add the Orca CLI directory to PATH before setup.' }) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d68eee67daa..0dd6e7f48c3 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1675,7 +1675,7 @@ function createPreflightApi(): NonNullable['preflight']> { function createCliApi(): NonNullable['cli']> { const status = { platform: getBrowserPlatform(), - commandName: 'orca', + commandName: getBrowserPlatform() === 'linux' ? 'orca-ide' : 'orca', commandPath: null, pathDirectory: null, pathConfigured: false, From 644214a8d47a2cc28f9d049c1e4e6d2debefd03a Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 16:59:20 -0700 Subject: [PATCH 034/224] fix: pr-bug-scan validated finding from #2972 Fix Settings > Agents availability updates so rapid queued writes preserve all requested disabled agents and repeated requests remain idempotent. --- .../components/settings/AgentsPane.test.tsx | 146 ++++++++++++++++-- .../src/components/settings/AgentsPane.tsx | 70 +++++++-- 2 files changed, 190 insertions(+), 26 deletions(-) diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index 22c3328ce5a..8d146ad11f5 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: Agents pane settings interactions share + store-backed queue fixtures that are easier to audit beside the UI helper coverage. */ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,7 +14,8 @@ import { AgentStatusHooksSetting, AgentsPane, AGENTS_PANE_SEARCH_ENTRIES, - buildAgentEnabledSettingsUpdate + buildAgentAvailabilitySettingsUpdate, + createAgentAvailabilityUpdateQueue } from './AgentsPane' import { matchesSettingsSearch } from './settings-search' @@ -35,6 +38,24 @@ type ReactElementLike = { props: Record } +type Deferred = { + promise: Promise + resolve: () => void +} + +function createDeferred(): Deferred { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +async function flushPromiseQueue(): Promise { + await Promise.resolve() + await Promise.resolve() +} + function renderPane( settings: GlobalSettings, props: Partial> = {} @@ -212,11 +233,11 @@ describe('AgentsPane', () => { }) it('only toggles agent availability when the segmented value changes', () => { - const onToggleEnabled = vi.fn() + const onSetEnabled = vi.fn() const control = AgentAvailabilityControl({ label: 'Claude', isEnabled: true, - onToggleEnabled + onSetEnabled }) const props = control.props as { value: 'enabled' | 'disabled' @@ -228,20 +249,21 @@ describe('AgentsPane', () => { expect(props.ariaLabel).toBe('Claude availability') props.onChange('enabled') - expect(onToggleEnabled).not.toHaveBeenCalled() + expect(onSetEnabled).not.toHaveBeenCalled() props.onChange('disabled') - expect(onToggleEnabled).toHaveBeenCalledTimes(1) + expect(onSetEnabled).toHaveBeenCalledWith(false) }) it('clears the default agent when disabling that agent', () => { expect( - buildAgentEnabledSettingsUpdate( + buildAgentAvailabilitySettingsUpdate( { defaultTuiAgent: 'claude', disabledTuiAgents: [] }, - 'claude' + 'claude', + false ) ).toEqual({ disabledTuiAgents: ['claude'], @@ -251,12 +273,13 @@ describe('AgentsPane', () => { it('keeps the default setting untouched when re-enabling an agent', () => { expect( - buildAgentEnabledSettingsUpdate( + buildAgentAvailabilitySettingsUpdate( { defaultTuiAgent: null, disabledTuiAgents: ['claude'] }, - 'claude' + 'claude', + true ) ).toEqual({ disabledTuiAgents: [] @@ -267,4 +290,109 @@ describe('AgentsPane', () => { expect(matchesSettingsSearch('wsl', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true) expect(matchesSettingsSearch('windows', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true) }) + + it('serializes rapid availability writes against the latest settings snapshot', async () => { + const queueAvailabilityUpdate = createAgentAvailabilityUpdateQueue() + const settings: GlobalSettings = { + ...getDefaultSettings('/tmp'), + defaultTuiAgent: null, + disabledTuiAgents: [] + } + const writes: Deferred[] = [] + const updates: Partial[] = [] + + useAppStore.setState({ settings }) + const updateSettings = vi.fn((update: Partial) => { + updates.push(update) + const nextSettings = { + ...(useAppStore.getState().settings ?? settings), + ...update + } + const write = createDeferred() + writes.push(write) + return write.promise.then(() => { + useAppStore.setState({ settings: nextSettings }) + }) + }) + + const firstWrite = queueAvailabilityUpdate({ + getSettings: () => useAppStore.getState().settings, + fallbackSettings: settings, + updateSettings, + agentId: 'claude', + enabled: false + }) + const secondWrite = queueAvailabilityUpdate({ + getSettings: () => useAppStore.getState().settings, + fallbackSettings: settings, + updateSettings, + agentId: 'codex', + enabled: false + }) + + await flushPromiseQueue() + expect(updateSettings).toHaveBeenCalledTimes(1) + expect(updates[0]).toMatchObject({ disabledTuiAgents: ['claude'] }) + + writes[0].resolve() + await firstWrite + await flushPromiseQueue() + + expect(updateSettings).toHaveBeenCalledTimes(2) + expect(updates[1]).toMatchObject({ disabledTuiAgents: ['claude', 'codex'] }) + + writes[1].resolve() + await secondWrite + }) + + it('keeps repeated queued availability requests idempotent', async () => { + const queueAvailabilityUpdate = createAgentAvailabilityUpdateQueue() + const settings: GlobalSettings = { + ...getDefaultSettings('/tmp'), + defaultTuiAgent: null, + disabledTuiAgents: [] + } + const writes: Deferred[] = [] + const updates: Partial[] = [] + + useAppStore.setState({ settings }) + const updateSettings = vi.fn((update: Partial) => { + updates.push(update) + const nextSettings = { + ...(useAppStore.getState().settings ?? settings), + ...update + } + const write = createDeferred() + writes.push(write) + return write.promise.then(() => { + useAppStore.setState({ settings: nextSettings }) + }) + }) + + const firstWrite = queueAvailabilityUpdate({ + getSettings: () => useAppStore.getState().settings, + fallbackSettings: settings, + updateSettings, + agentId: 'claude', + enabled: false + }) + const secondWrite = queueAvailabilityUpdate({ + getSettings: () => useAppStore.getState().settings, + fallbackSettings: settings, + updateSettings, + agentId: 'claude', + enabled: false + }) + + await flushPromiseQueue() + writes[0].resolve() + await firstWrite + await flushPromiseQueue() + + expect(updateSettings).toHaveBeenCalledTimes(2) + expect(updates[1]).toMatchObject({ disabledTuiAgents: ['claude'] }) + + writes[1].resolve() + await secondWrite + }) }) diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 99eb3ef9b48..0a002ab017c 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -34,6 +34,14 @@ type AgentsPaneProps = { wslCapabilitiesLoading?: boolean } +type AgentAvailabilityUpdateQueueOptions = { + getSettings: () => GlobalSettings | null | undefined + fallbackSettings: GlobalSettings + updateSettings: AgentsPaneProps['updateSettings'] + agentId: TuiAgent + enabled: boolean +} + type AgentRowProps = { agentId: TuiAgent label: string @@ -44,7 +52,7 @@ type AgentRowProps = { isDefault: boolean cmdOverride: string | undefined onSetDefault: () => void - onToggleEnabled: () => void + onSetEnabled: (enabled: boolean) => void onSaveOverride: (value: string) => void } @@ -59,29 +67,52 @@ type AgentAvailability = 'enabled' | 'disabled' type AgentAvailabilityControlProps = { label: string isEnabled: boolean - onToggleEnabled: () => void + onSetEnabled: (enabled: boolean) => void } -export function buildAgentEnabledSettingsUpdate( +export function buildAgentAvailabilitySettingsUpdate( settings: Pick, - id: TuiAgent + id: TuiAgent, + enabled: boolean ): Pick & Partial> { const latestDisabled = normalizeDisabledTuiAgents(settings.disabledTuiAgents) - const wasDisabled = latestDisabled.includes(id) - const nextDisabled = wasDisabled + const nextDisabled = enabled ? latestDisabled.filter((agent) => agent !== id) - : [...latestDisabled, id] + : latestDisabled.includes(id) + ? latestDisabled + : [...latestDisabled, id] return { disabledTuiAgents: nextDisabled, - ...(settings.defaultTuiAgent === id && !wasDisabled ? { defaultTuiAgent: null } : {}) + ...(settings.defaultTuiAgent === id && !enabled ? { defaultTuiAgent: null } : {}) } } +export function createAgentAvailabilityUpdateQueue(): ( + options: AgentAvailabilityUpdateQueueOptions +) => Promise { + let pendingUpdate: Promise = Promise.resolve() + + return ({ getSettings, fallbackSettings, updateSettings, agentId, enabled }) => { + // Why: serialize full-array replacements so each write sees the store after + // the previous IPC has reconciled, while preserving the user's requested state. + pendingUpdate = pendingUpdate + .catch(() => {}) + .then(() => + updateSettings( + buildAgentAvailabilitySettingsUpdate(getSettings() ?? fallbackSettings, agentId, enabled) + ) + ) + return pendingUpdate.then(() => undefined) + } +} + +const enqueueAgentAvailabilityUpdate = createAgentAvailabilityUpdateQueue() + export function AgentAvailabilityControl({ label, isEnabled, - onToggleEnabled + onSetEnabled }: AgentAvailabilityControlProps): React.JSX.Element { const value: AgentAvailability = isEnabled ? 'enabled' : 'disabled' @@ -90,7 +121,7 @@ export function AgentAvailabilityControl({ value={value} onChange={(next) => { if (next !== value) { - onToggleEnabled() + onSetEnabled(next === 'enabled') } }} ariaLabel={`${label} availability`} @@ -170,7 +201,7 @@ function AgentRow({ isDefault, cmdOverride, onSetDefault, - onToggleEnabled, + onSetEnabled, onSaveOverride }: AgentRowProps): React.JSX.Element { const [cmdOpen, setCmdOpen] = useState(Boolean(cmdOverride)) @@ -216,7 +247,7 @@ function AgentRow({ {isDetected && isEnabled && ( @@ -346,9 +377,14 @@ export function AgentsPane({ updateSettings({ defaultTuiAgent: id }) } - const toggleEnabled = (id: TuiAgent): void => { - const latestSettings = useAppStore.getState().settings ?? settings - updateSettings(buildAgentEnabledSettingsUpdate(latestSettings, id)) + const setAgentEnabled = (id: TuiAgent, enabled: boolean): void => { + void enqueueAgentAvailabilityUpdate({ + getSettings: () => useAppStore.getState().settings, + fallbackSettings: settings, + updateSettings, + agentId: id, + enabled + }) } const saveOverride = (id: TuiAgent, value: string): void => { @@ -474,7 +510,7 @@ export function AgentsPane({ isDefault={defaultAgent === agent.id} cmdOverride={cmdOverrides[agent.id]} onSetDefault={() => setDefault(agent.id)} - onToggleEnabled={() => toggleEnabled(agent.id)} + onSetEnabled={(enabled) => setAgentEnabled(agent.id, enabled)} onSaveOverride={(v) => saveOverride(agent.id, v)} /> ))} @@ -506,7 +542,7 @@ export function AgentsPane({ isDefault={false} cmdOverride={undefined} onSetDefault={() => {}} - onToggleEnabled={() => toggleEnabled(agent.id)} + onSetEnabled={(enabled) => setAgentEnabled(agent.id, enabled)} onSaveOverride={() => {}} /> ))} From b581f64daf05d0cb41f7dffbff13d211ec7159bf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 17:07:04 -0700 Subject: [PATCH 035/224] fix: dispose local image cache lifecycle (#3382) --- .../src/components/editor/useLocalImageSrc.ts | 50 ++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index 9efbd9dc5f0..1f8e7b8f432 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -45,6 +45,8 @@ function cacheBlobUrl(key: string, url: string): void { } const cacheListeners = new Set<() => void>() let cacheGeneration = 0 +const pendingBlobUrlRevocations = new Set() +let pendingBlobUrlRevocationTimer: ReturnType | null = null function base64ToBlobUrl(base64: string, mimeType: string): string { const binary = atob(base64.replace(/\s/g, '')) @@ -55,12 +57,30 @@ function base64ToBlobUrl(base64: string, mimeType: string): string { return URL.createObjectURL(new Blob([bytes], { type: mimeType })) } +function revokePendingBlobUrls(): void { + pendingBlobUrlRevocationTimer = null + for (const url of pendingBlobUrlRevocations) { + URL.revokeObjectURL(url) + } + pendingBlobUrlRevocations.clear() +} + +function scheduleBlobUrlRevocation(urls: string[]): void { + for (const url of urls) { + pendingBlobUrlRevocations.add(url) + } + if (pendingBlobUrlRevocationTimer !== null || pendingBlobUrlRevocations.size === 0) { + return + } + pendingBlobUrlRevocationTimer = setTimeout(revokePendingBlobUrls, 30_000) +} + // Why: when the user switches back to the app after deleting or replacing // image files externally, clearing the cache forces the preview to pick up // the current filesystem state instead of showing stale in-memory blob URLs. // Old blob URLs are revoked after a short delay so that elements still // display the old data while the fresh IPC load completes, avoiding a visible -// flash. The 5-second window is generous enough for even slow IPC reads. +// flash. The 30-second window is generous enough for even slow IPC reads. function invalidateImageCache(): void { const staleUrls = Array.from(blobUrlCache.values()) blobUrlCache.clear() @@ -73,18 +93,36 @@ function invalidateImageCache(): void { // 30 seconds is generous enough to cover slow machines or large images // without risking a visible broken-image flash. if (staleUrls.length > 0) { - setTimeout(() => { - for (const url of staleUrls) { - URL.revokeObjectURL(url) - } - }, 30_000) + scheduleBlobUrlRevocation(staleUrls) } } +function disposeImageCacheModuleState(): void { + if (typeof window !== 'undefined') { + window.removeEventListener('focus', invalidateImageCache) + } + if (pendingBlobUrlRevocationTimer !== null) { + clearTimeout(pendingBlobUrlRevocationTimer) + pendingBlobUrlRevocationTimer = null + } + revokePendingBlobUrls() + for (const url of blobUrlCache.values()) { + URL.revokeObjectURL(url) + } + blobUrlCache.clear() + cacheListeners.clear() +} + if (typeof window !== 'undefined') { window.addEventListener('focus', invalidateImageCache) } +if (typeof import.meta !== 'undefined' && import.meta.hot) { + // Why: Vite can re-evaluate this module without a full renderer reload. + // Disposing the module-level listener and blob URLs prevents dev-session leaks. + import.meta.hot.dispose(disposeImageCacheModuleState) +} + /** * Subscribe to cache invalidation events (fired on window re-focus). * Returns an unsubscribe function. From 191183882a10f8066dbfc8a4cf9dd22f71ee7fdb Mon Sep 17 00:00:00 2001 From: Jason Weingardt Date: Fri, 29 May 2026 20:10:47 -0400 Subject: [PATCH 036/224] feat: add iPad responsive layout support (#2433) --- mobile/app.json | 4 +- mobile/app/h/[hostId]/index.tsx | 10 ++++- mobile/app/index.tsx | 18 +++++++- mobile/src/components/BottomDrawer.tsx | 12 ++++- .../layout/responsive-layout-metrics.test.ts | 39 ++++++++++++++++ .../src/layout/responsive-layout-metrics.ts | 44 +++++++++++++++++++ mobile/src/layout/responsive-layout.ts | 12 +++++ 7 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 mobile/src/layout/responsive-layout-metrics.test.ts create mode 100644 mobile/src/layout/responsive-layout-metrics.ts create mode 100644 mobile/src/layout/responsive-layout.ts diff --git a/mobile/app.json b/mobile/app.json index 57e43ffdd01..7d77ed5341d 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -3,7 +3,7 @@ "name": "Orca", "slug": "orca-mobile", "version": "0.0.10", - "orientation": "portrait", + "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", "scheme": "orca", @@ -14,7 +14,7 @@ "backgroundColor": "#111111" }, "ios": { - "supportsTablet": false, + "supportsTablet": true, "bundleIdentifier": "com.stably.orca.mobile", "buildNumber": "1", "infoPlist": { diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 194bbcd4a04..fc4765448c6 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -54,6 +54,7 @@ import { BottomDrawer } from '../../../src/components/BottomDrawer' import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen' import { getCachedWorktrees } from '../../../src/cache/worktree-cache' import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' +import { useResponsiveLayout } from '../../../src/layout/responsive-layout' import { evaluateCompat, type CompatVerdict } from '../../../src/transport/protocol-compat' import { loadPinnedIds, @@ -302,6 +303,9 @@ export default function HostScreen() { const { hostId, action } = useLocalSearchParams<{ hostId: string; action?: string }>() const router = useRouter() const insets = useSafeAreaInsets() + // Why: cap and center the worktree list on wide/tablet canvases; on phones + // isWideLayout is false so the list stays edge-to-edge as before. + const { isWideLayout, contentMaxWidth } = useResponsiveLayout() const [initialCache] = useState(() => hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null ) @@ -956,7 +960,11 @@ export default function HostScreen() { // Why: edge-to-edge — the list scrolls under the system nav bar // while reserving insets.bottom keeps the last worktree row reachable // above the Samsung 3-button nav / iOS home indicator. - contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]} + contentContainerStyle={[ + styles.list, + { paddingBottom: spacing.lg + insets.bottom }, + isWideLayout && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } + ]} renderSectionHeader={({ section }) => { if (!section.title) return null const isCollapsed = collapsedGroups.has(section.title) diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index e0d13d1cd88..3486fd441bc 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -48,6 +48,7 @@ import { normalizeVisibleTaskProviders, type TaskProvider } from '../src/tasks/mobile-task-providers' +import { useResponsiveLayout } from '../src/layout/responsive-layout' function endpointLabel(endpoint: string): string { try { @@ -270,6 +271,9 @@ function repoColor(name: string): string { export default function HomeScreen() { const router = useRouter() const insets = useSafeAreaInsets() + // Why: cap and center content on wide/tablet canvases so cards don't stretch + // edge-to-edge on iPad; on phones isWideLayout is false and layout is unchanged. + const { isWideLayout, contentMaxWidth } = useResponsiveLayout() const [hosts, setHosts] = useState([]) const [actionTarget, setActionTarget] = useState(null) const [renameTarget, setRenameTarget] = useState(null) @@ -676,7 +680,13 @@ export default function HomeScreen() { {hosts.length === 0 ? ( /* ─── Empty state: onboarding ─── */ - + Connect your desktop @@ -712,7 +722,11 @@ export default function HomeScreen() { // Why: edge-to-edge — let the list scroll under the system nav bar // but reserve insets.bottom so the last row stays reachable above // the Samsung 3-button nav / iOS home indicator. - contentContainerStyle={[styles.list, { paddingBottom: spacing.xl + insets.bottom }]} + contentContainerStyle={[ + styles.list, + { paddingBottom: spacing.xl + insets.bottom }, + isWideLayout && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } + ]} ListHeaderComponent={ diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 88b6e0e7293..e96f035c2a7 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -22,6 +22,7 @@ import Animated, { Extrapolation } from 'react-native-reanimated' import { colors, spacing } from '../theme/mobile-theme' +import { useResponsiveLayout } from '../layout/responsive-layout' const DISMISS_THRESHOLD = 80 const SPRING_CONFIG = { damping: 28, stiffness: 400 } @@ -93,6 +94,10 @@ function MountedBottomDrawer({ const contentDragCanDismiss = useSharedValue(false) const { height: screenHeight } = useWindowDimensions() const insets = useSafeAreaInsets() + // Why: on wide/tablet canvases a full-width sheet looks stretched; cap it and + // center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard + // transforms below) is unchanged, so phone behavior stays identical. + const { isWideLayout, modalMaxWidth } = useResponsiveLayout() useEffect(() => { if (visible) { @@ -258,11 +263,13 @@ function MountedBottomDrawer({ - + { + it('uses capped tablet layout for iPad portrait and landscape windows', () => { + expect(getResponsiveLayoutMetrics(820, 1180)).toMatchObject({ + isLandscape: false, + isTabletLayout: true, + isWideLayout: true, + contentMaxWidth: 720, + modalMaxWidth: 480, + horizontalPadding: spacing.xl + }) + + expect(getResponsiveLayoutMetrics(1180, 820)).toMatchObject({ + isLandscape: true, + isTabletLayout: true, + isWideLayout: true + }) + }) + + it('keeps narrow iPad split windows phone-like', () => { + expect(getResponsiveLayoutMetrics(560, 1024)).toMatchObject({ + isTabletLayout: false, + isWideLayout: false, + horizontalPadding: spacing.lg + }) + }) + + it('keeps landscape phones out of wide tablet layout', () => { + expect(getResponsiveLayoutMetrics(932, 430)).toMatchObject({ + isLandscape: true, + isTabletLayout: false, + isWideLayout: false, + horizontalPadding: spacing.lg + }) + }) +}) diff --git a/mobile/src/layout/responsive-layout-metrics.ts b/mobile/src/layout/responsive-layout-metrics.ts new file mode 100644 index 00000000000..4fffbcea989 --- /dev/null +++ b/mobile/src/layout/responsive-layout-metrics.ts @@ -0,0 +1,44 @@ +import { spacing } from '../theme/mobile-theme' + +// Use actual window size so narrow iPad splits keep phone-like layouts. +const WIDE_LAYOUT_MIN_WIDTH = 700 + +// Why: width alone catches landscape phones; capped tablet layouts need room +// in both dimensions so phone rotation does not switch UI classes. +const TABLET_LAYOUT_MIN_SHORT_SIDE = 600 + +const CONTENT_MAX_WIDTH = 720 +const MODAL_MAX_WIDTH = 480 + +export type ResponsiveLayoutMetrics = { + width: number + height: number + isLandscape: boolean + /** Window is wide enough to cap and center primary content. */ + isWideLayout: boolean + /** Tablet-class canvas (both dimensions large); false in narrow splits. */ + isTabletLayout: boolean + /** Max width for primary scrollable content on wide layouts. */ + contentMaxWidth: number + /** Max width for centered sheets/dialogs on wide layouts. */ + modalMaxWidth: number + /** Recommended horizontal gutter for the current width. */ + horizontalPadding: number +} + +export function getResponsiveLayoutMetrics(width: number, height: number): ResponsiveLayoutMetrics { + const isTabletLayout = Math.min(width, height) >= TABLET_LAYOUT_MIN_SHORT_SIDE + const isWideLayout = width >= WIDE_LAYOUT_MIN_WIDTH && isTabletLayout + + return { + width, + height, + isLandscape: width > height, + isWideLayout, + isTabletLayout, + contentMaxWidth: CONTENT_MAX_WIDTH, + modalMaxWidth: MODAL_MAX_WIDTH, + // Roomier gutters once content is capped so it isn't glued to the edges. + horizontalPadding: isWideLayout ? spacing.xl : spacing.lg + } +} diff --git a/mobile/src/layout/responsive-layout.ts b/mobile/src/layout/responsive-layout.ts new file mode 100644 index 00000000000..9a93afbaa42 --- /dev/null +++ b/mobile/src/layout/responsive-layout.ts @@ -0,0 +1,12 @@ +import { useWindowDimensions } from 'react-native' +import { + getResponsiveLayoutMetrics, + type ResponsiveLayoutMetrics +} from './responsive-layout-metrics' + +export type ResponsiveLayout = ResponsiveLayoutMetrics + +export function useResponsiveLayout(): ResponsiveLayout { + const { width, height } = useWindowDimensions() + return getResponsiveLayoutMetrics(width, height) +} From 2a08d6863124a96fcfa170db5cab7547aaffdfa8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 17:13:15 -0700 Subject: [PATCH 037/224] fix: clear transient ui feedback timers (#3385) --- src/renderer/src/components/QuickOpen.tsx | 12 +++++++++-- .../components/editor/CodeBlockCopyButton.tsx | 11 ++++++++-- .../editor/RichMarkdownCodeBlock.tsx | 11 ++++++++-- .../right-sidebar/checks-panel-content.tsx | 20 ++++++++++++++++--- .../components/settings/McpConfigSection.tsx | 9 ++++++++- .../src/components/settings/MobilePane.tsx | 9 ++++++++- .../components/settings/RepositoryPane.tsx | 11 ++++++++-- .../src/components/stats/ShareUsageButton.tsx | 11 ++++++++-- 8 files changed, 79 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index ba0bf4f3060..7180862bc61 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -1,5 +1,5 @@ /* oxlint-disable max-lines */ -import React, { useCallback, useDeferredValue, useMemo, useState } from 'react' +import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' import { AlertTriangle, Check, Copy } from 'lucide-react' import { useAppStore } from '@/store' import { useActiveWorktree } from '@/store/selectors' @@ -66,6 +66,15 @@ function InstallRgGuidance({ guidance?: string | null }): React.JSX.Element { const [copied, setCopied] = useState(false) + + useEffect(() => { + if (!copied) { + return + } + const timeout = window.setTimeout(() => setCopied(false), 1500) + return () => window.clearTimeout(timeout) + }, [copied]) + const handleCopy = useCallback(() => { if (!command) { return @@ -78,7 +87,6 @@ function InstallRgGuidance({ .writeClipboardText(command) .then(() => { setCopied(true) - setTimeout(() => setCopied(false), 1500) }) .catch(() => { /* best-effort */ diff --git a/src/renderer/src/components/editor/CodeBlockCopyButton.tsx b/src/renderer/src/components/editor/CodeBlockCopyButton.tsx index c65e1ddafcd..ffda79aab05 100644 --- a/src/renderer/src/components/editor/CodeBlockCopyButton.tsx +++ b/src/renderer/src/components/editor/CodeBlockCopyButton.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { Copy, Check } from 'lucide-react' type CodeBlockCopyButtonProps = React.HTMLAttributes & { @@ -11,6 +11,14 @@ export default function CodeBlockCopyButton({ }: CodeBlockCopyButtonProps): React.JSX.Element { const [copied, setCopied] = useState(false) + useEffect(() => { + if (!copied) { + return + } + const timeout = window.setTimeout(() => setCopied(false), 1500) + return () => window.clearTimeout(timeout) + }, [copied]) + const handleCopy = useCallback(() => { // Extract the text content from the nested element rendered by // react-markdown inside
. We walk the React children tree to grab the
@@ -29,7 +37,6 @@ export default function CodeBlockCopyButton({
       .writeClipboardText(text)
       .then(() => {
         setCopied(true)
-        setTimeout(() => setCopied(false), 1500)
       })
       .catch(() => {
         // Silently swallow clipboard write failures (e.g. permission denied).
diff --git a/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx b/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx
index 1063e4a00e8..914fddd3d06 100644
--- a/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx
+++ b/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useState } from 'react'
+import React, { useCallback, useEffect, useState } from 'react'
 import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
 import type { NodeViewProps } from '@tiptap/react'
 import { Copy, Check } from 'lucide-react'
@@ -51,6 +51,14 @@ export function RichMarkdownCodeBlock({
 
   const isMermaid = language === 'mermaid'
 
+  useEffect(() => {
+    if (!copied) {
+      return
+    }
+    const timeout = window.setTimeout(() => setCopied(false), 1500)
+    return () => window.clearTimeout(timeout)
+  }, [copied])
+
   const onChange = useCallback(
     (e: React.ChangeEvent) => {
       updateAttributes({ language: e.target.value })
@@ -66,7 +74,6 @@ export function RichMarkdownCodeBlock({
         .writeClipboardText(text)
         .then(() => {
           setCopied(true)
-          setTimeout(() => setCopied(false), 1500)
         })
         .catch(() => {
           // Silently swallow clipboard write failures (e.g. permission denied).
diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
index a1586cf5700..24bee80a4f1 100644
--- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
+++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
@@ -1,6 +1,6 @@
 /* eslint-disable max-lines -- Why: co-locating all checks-panel sub-components (checks list,
 conflict sections, threaded PR comments) keeps the shared icon/color maps in one place. */
-import React, { useCallback, useState } from 'react'
+import React, { useCallback, useEffect, useState } from 'react'
 import {
   CircleCheck,
   CircleX,
@@ -357,12 +357,19 @@ export function ChecksList({
 function CopyButton({ text }: { text: string }): React.JSX.Element {
   const [copied, setCopied] = useState(false)
 
+  useEffect(() => {
+    if (!copied) {
+      return
+    }
+    const timeout = window.setTimeout(() => setCopied(false), 1500)
+    return () => window.clearTimeout(timeout)
+  }, [copied])
+
   const handleCopy = useCallback(
     (e: React.MouseEvent) => {
       e.stopPropagation()
       void window.api.ui.writeClipboardText(text).then(() => {
         setCopied(true)
-        setTimeout(() => setCopied(false), 1500)
       })
     },
     [text]
@@ -390,12 +397,19 @@ function ResolveButton({
 }): React.JSX.Element {
   const [loading, setLoading] = useState(false)
 
+  useEffect(() => {
+    if (!loading) {
+      return
+    }
+    const timeout = window.setTimeout(() => setLoading(false), 300)
+    return () => window.clearTimeout(timeout)
+  }, [loading])
+
   const handleClick = useCallback(
     (e: React.MouseEvent) => {
       e.stopPropagation()
       setLoading(true)
       onResolve(threadId, !isResolved)
-      setTimeout(() => setLoading(false), 300)
     },
     [threadId, isResolved, onResolve]
   )
diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx
index 6506e332dff..da082c6e9a3 100644
--- a/src/renderer/src/components/settings/McpConfigSection.tsx
+++ b/src/renderer/src/components/settings/McpConfigSection.tsx
@@ -215,6 +215,14 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
     void loadConfigs()
   }, [loadConfigs])
 
+  useEffect(() => {
+    if (!createConfirm) {
+      return
+    }
+    const timeout = window.setTimeout(() => setCreateConfirm(false), 3000)
+    return () => window.clearTimeout(timeout)
+  }, [createConfirm])
+
   const handleOpen = (config: LoadedMcpConfigInspection): void => {
     setActiveWorktree(targetWorktreeId)
     const targetGroupId = ensureWorktreeRootGroup(targetWorktreeId)
@@ -234,7 +242,6 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
   const handleCreateStarter = async (): Promise => {
     if (!createConfirm) {
       setCreateConfirm(true)
-      window.setTimeout(() => setCreateConfirm(false), 3000)
       return
     }
 
diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx
index 6fe60926ebd..db297eccf74 100644
--- a/src/renderer/src/components/settings/MobilePane.tsx
+++ b/src/renderer/src/components/settings/MobilePane.tsx
@@ -53,6 +53,14 @@ export function MobilePane(): React.JSX.Element {
   const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false)
   const [codeCopied, setCodeCopied] = useState(false)
 
+  useEffect(() => {
+    if (!codeCopied) {
+      return
+    }
+    const timeout = window.setTimeout(() => setCodeCopied(false), 2000)
+    return () => window.clearTimeout(timeout)
+  }, [codeCopied])
+
   const loadDevices = useCallback(async () => {
     try {
       const result = await window.api.mobile.listDevices()
@@ -141,7 +149,6 @@ export function MobilePane(): React.JSX.Element {
       // IPC clipboard which the rest of the app uses everywhere.
       await window.api.ui.writeClipboardText(pairingUrl)
       setCodeCopied(true)
-      setTimeout(() => setCodeCopied(false), 2000)
     } catch {
       toast.error('Failed to copy pairing code')
     }
diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx
index 8bdb528f5d5..2bf4a777cc0 100644
--- a/src/renderer/src/components/settings/RepositoryPane.tsx
+++ b/src/renderer/src/components/settings/RepositoryPane.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
 import type { OrcaHooks, Repo, RepoHookSettings } from '../../../../shared/types'
 import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
 import { Button } from '../ui/button'
@@ -59,6 +59,14 @@ export function RepositoryPane({
   // request to hide every child row that does not repeat the project name.
   const forceFullPaneForRepoMatch = matchesRepositoryIdentitySearch(searchQuery, repo)
 
+  useEffect(() => {
+    if (!copiedTemplate) {
+      return
+    }
+    const timeout = window.setTimeout(() => setCopiedTemplate(false), 1500)
+    return () => window.clearTimeout(timeout)
+  }, [copiedTemplate])
+
   const handleRemoveProject = (repoId: string) => {
     if (confirmingRemove === repoId) {
       removeProject(repoId)
@@ -84,7 +92,6 @@ export function RepositoryPane({
   archive: |
     echo "Cleaning up before archive"`)
     setCopiedTemplate(true)
-    window.setTimeout(() => setCopiedTemplate(false), 1500)
   }
 
   const allEntries = getRepositoryPaneSearchEntries(repo)
diff --git a/src/renderer/src/components/stats/ShareUsageButton.tsx b/src/renderer/src/components/stats/ShareUsageButton.tsx
index 40d4093aa48..7e85055f39f 100644
--- a/src/renderer/src/components/stats/ShareUsageButton.tsx
+++ b/src/renderer/src/components/stats/ShareUsageButton.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useRef, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
 import { toPng } from 'html-to-image'
 import { Check, Copy, Share2 } from 'lucide-react'
 import { Button } from '../ui/button'
@@ -21,6 +21,14 @@ export function ShareUsageButton(props: ShareUsageButtonProps): React.JSX.Elemen
   const [copied, setCopied] = useState(false)
   const [capturing, setCapturing] = useState(false)
 
+  useEffect(() => {
+    if (!copied) {
+      return
+    }
+    const timeout = window.setTimeout(() => setCopied(false), 2000)
+    return () => window.clearTimeout(timeout)
+  }, [copied])
+
   const captureToClipboard = useCallback(async () => {
     if (!cardRef.current || capturing) {
       return
@@ -42,7 +50,6 @@ export function ShareUsageButton(props: ShareUsageButtonProps): React.JSX.Elemen
     const ok = await captureToClipboard()
     if (ok) {
       setCopied(true)
-      setTimeout(() => setCopied(false), 2000)
     }
   }, [captureToClipboard])
 

From 4180352856915aef8fbb61edced4a62a122eae9a Mon Sep 17 00:00:00 2001
From: Gannon Hall 
Date: Fri, 29 May 2026 17:16:11 -0700
Subject: [PATCH 038/224] fix: auto-populate Create Worktree branch tab (#2850)

* fix(new-workspace): show branch results before typing

Query and render Branch tab results for empty input so users can pick existing refs immediately.

* refactor(new-workspace): simplify branch source helpers

Remove thin abstractions from the branch result helper while preserving Branch tab behavior.

* fix(new-workspace): load branch options for empty search

* refactor(new-workspace): keep smart branch search lazy

Limit empty-query branch loading to the Branch tab so Smart mode keeps its start-typing state.

* fix(runtime): populate remote branch searches when empty

Keep runtime-backed repos aligned with the local and IPC branch search paths.

* fix: harden branch search prepopulation

Co-authored-by: Orca 

* test: align branch search assertions after merge

Co-authored-by: Orca 

---------

Co-authored-by: Jinwoo-H 
Co-authored-by: Orca 
---
 src/main/git/repo-search-ref-compat.test.ts   |  47 +++++
 src/main/git/repo.test.ts                     |  27 ++-
 src/main/git/repo.ts                          |  73 ++++++-
 src/main/ipc/repos-remote.test.ts             | 117 +++++++++--
 src/main/ipc/repos.ts                         |  29 ++-
 src/main/runtime/orca-runtime.test.ts         | 112 +++++++++++
 src/main/runtime/orca-runtime.ts              |  22 ++-
 .../new-workspace/SmartWorkspaceNameField.tsx | 185 ++++++++----------
 .../smart-workspace-source-results.test.ts    | 108 ++++++++++
 .../smart-workspace-source-results.ts         | 153 +++++++++++++++
 10 files changed, 721 insertions(+), 152 deletions(-)
 create mode 100644 src/main/git/repo-search-ref-compat.test.ts
 create mode 100644 src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts
 create mode 100644 src/renderer/src/components/new-workspace/smart-workspace-source-results.ts

diff --git a/src/main/git/repo-search-ref-compat.test.ts b/src/main/git/repo-search-ref-compat.test.ts
new file mode 100644
index 00000000000..9e9659a11b2
--- /dev/null
+++ b/src/main/git/repo-search-ref-compat.test.ts
@@ -0,0 +1,47 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const { gitExecFileAsyncMock } = vi.hoisted(() => ({
+  gitExecFileAsyncMock: vi.fn()
+}))
+
+vi.mock('./runner', () => ({
+  gitExecFileAsync: gitExecFileAsyncMock,
+  gitExecFileSync: vi.fn()
+}))
+
+import { searchBaseRefs } from './repo'
+
+describe('searchBaseRefs git compatibility', () => {
+  afterEach(() => {
+    gitExecFileAsyncMock.mockReset()
+  })
+
+  it('falls back when older git does not support for-each-ref --exclude', async () => {
+    gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
+      if (args[0] === 'remote') {
+        return { stdout: 'origin\n', stderr: '' }
+      }
+      if (args.includes('--exclude=refs/remotes/**/HEAD')) {
+        throw Object.assign(new Error("unknown option `exclude'"), {
+          stderr: "error: unknown option `exclude'"
+        })
+      }
+      return {
+        stdout: [
+          'refs/remotes/origin/main\0origin/main',
+          'refs/remotes/origin/HEAD\0origin/HEAD'
+        ].join('\n'),
+        stderr: ''
+      }
+    })
+
+    await expect(searchBaseRefs('/repo', '', 1)).resolves.toEqual(['origin/main'])
+    const forEachRefCalls = gitExecFileAsyncMock.mock.calls.filter(
+      (call) => (call[0] as string[])[0] === 'for-each-ref'
+    )
+    expect(forEachRefCalls).toHaveLength(2)
+    expect(forEachRefCalls[0][0]).toContain('--exclude=refs/remotes/**/HEAD')
+    expect(forEachRefCalls[1][0]).not.toContain('--exclude=refs/remotes/**/HEAD')
+    expect(forEachRefCalls[1][0]).toContain('--count=104')
+  })
+})
diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts
index 8cdb1852b45..8521d34698e 100644
--- a/src/main/git/repo.test.ts
+++ b/src/main/git/repo.test.ts
@@ -5,6 +5,7 @@ import { tmpdir } from 'os'
 import path from 'path'
 
 import {
+  buildSearchBaseRefsArgv,
   getDefaultBaseRef,
   getBranchConflictKind,
   getRemoteCount,
@@ -218,10 +219,32 @@ describe('searchBaseRefs (widened glob)', () => {
     expect(results).toEqual([])
   })
 
-  it('returns [] without error for an empty query', async () => {
+  it('returns recent refs for an empty query so branch pickers can open populated', async () => {
+    const sha = getHeadSha(tmpDir)
+    createRemoteRef(tmpDir, 'upstream/main', sha)
+    createRemoteRef(tmpDir, 'upstream/feature-x', sha)
+
     const results = await searchBaseRefs(tmpDir, '')
 
-    expect(results).toEqual([])
+    expect(results).toEqual(['main', 'upstream/feature-x', 'upstream/main'])
+  })
+
+  it('caps broad ref-search argv before git output is captured', () => {
+    const argv = buildSearchBaseRefsArgv('', 12)
+
+    expect(argv).toContain('--exclude=refs/remotes/**/HEAD')
+    expect(argv).toContain('--count=48')
+  })
+
+  it('does not hard-cap large explicit ref-search limits below the request size', () => {
+    const argv = buildSearchBaseRefsArgv('', 600)
+
+    expect(argv).toContain('--count=2400')
+  })
+
+  it('returns [] for invalid search limits instead of running an uncapped search', async () => {
+    await expect(searchBaseRefs(tmpDir, '', 0.5)).resolves.toEqual([])
+    await expect(searchBaseRefs(tmpDir, '', Number.NaN)).resolves.toEqual([])
   })
 
   // Why: the picker displays results as `/` and labels
diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts
index ad23d8e777d..fff8ad5a558 100644
--- a/src/main/git/repo.ts
+++ b/src/main/git/repo.ts
@@ -478,8 +478,39 @@ async function getDefaultBaseRefAsync(path: string): Promise {
  * Why shared: the local path and the SSH relay path must send the exact
  * same argv so results cannot diverge between transports.
  */
-export function buildSearchBaseRefsArgv(normalizedQuery: string): string[] {
-  const base = ['for-each-ref', '--format=%(refname)%00%(refname:short)', '--sort=-committerdate']
+const REF_SEARCH_CANDIDATE_MULTIPLIER = 4
+const REF_SEARCH_LEGACY_HEADROOM = 100
+
+function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number {
+  if (!Number.isInteger(limit) || limit <= 0) {
+    throw new Error('invalid_limit')
+  }
+  const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER
+  return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM
+}
+
+export function buildSearchBaseRefsArgv(
+  normalizedQuery: string,
+  limit: number,
+  options: { excludeRemoteHead?: boolean } = {}
+): string[] {
+  const excludeRemoteHead = options.excludeRemoteHead ?? true
+  const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead)
+  const base = [
+    'for-each-ref',
+    '--format=%(refname)%00%(refname:short)',
+    '--sort=-committerdate',
+    ...(excludeRemoteHead
+      ? [
+          // Why: exclude remote HEAD pseudo-refs before --count so the bounded
+          // candidate window is spent on refs the picker can actually display.
+          '--exclude=refs/remotes/**/HEAD'
+        ]
+      : []),
+    // Why: empty Branch-tab searches use broad globs; cap git output before
+    // execFile/SSH buffers capture every ref in very large repositories.
+    `--count=${candidateCount}`
+  ]
   // Why: split on `/` so display-format queries (`upstream/main`) route
   // each token to one git ref segment. Filter empty tokens so trailing
   // (`upstream/`), leading (`/main`), or doubled (`upstream//main`)
@@ -512,6 +543,18 @@ export function buildSearchBaseRefsArgv(normalizedQuery: string): string[] {
   return [...base, `refs/remotes/${segmented}`, `refs/heads/${segmented}`]
 }
 
+export function isForEachRefExcludeUnsupportedError(error: unknown): boolean {
+  if (!error || typeof error !== 'object') {
+    return false
+  }
+  const maybe = error as { message?: unknown; stderr?: unknown; stdout?: unknown }
+  const text = [maybe.message, maybe.stderr, maybe.stdout]
+    .filter((value): value is string => typeof value === 'string')
+    .join('\n')
+    .toLowerCase()
+  return text.includes('unknown option') && text.includes('exclude')
+}
+
 /**
  * Resolve the default push remote for a repo.
  * Order: remote configured on the current default branch → origin → the single
@@ -578,20 +621,32 @@ export async function searchBaseRefDetails(
   query: string,
   limit = 25
 ): Promise {
-  const normalizedQuery = normalizeRefSearchQuery(query)
-  if (!normalizedQuery) {
+  if (!Number.isInteger(limit) || limit <= 0) {
     return []
   }
+  const normalizedQuery = normalizeRefSearchQuery(query)
 
   try {
     // Why: argv (including the two-remote-glob rationale) lives in
     // buildSearchBaseRefsArgv so the SSH sibling cannot drift.
-    const [{ stdout }, remotes] = await Promise.all([
-      gitExecFileAsync(buildSearchBaseRefsArgv(normalizedQuery), { cwd: path }),
-      listRemoteNames(path)
-    ])
+    const remotesPromise = listRemoteNames(path)
+    let result: { stdout: string }
+    try {
+      result = await gitExecFileAsync(buildSearchBaseRefsArgv(normalizedQuery, limit), {
+        cwd: path
+      })
+    } catch (err) {
+      if (!isForEachRefExcludeUnsupportedError(err)) {
+        throw err
+      }
+      result = await gitExecFileAsync(
+        buildSearchBaseRefsArgv(normalizedQuery, limit, { excludeRemoteHead: false }),
+        { cwd: path }
+      )
+    }
+    const remotes = await remotesPromise
 
-    return parseAndFilterSearchRefDetails(stdout, limit, remotes)
+    return parseAndFilterSearchRefDetails(result.stdout, limit, remotes)
   } catch (err) {
     // Why: surface the failure for diagnostics; callers treat `[]` as "no
     // matches", but silently swallowing the error makes a missing result
diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts
index da6b5c79f92..8af78912ad3 100644
--- a/src/main/ipc/repos-remote.test.ts
+++ b/src/main/ipc/repos-remote.test.ts
@@ -1332,7 +1332,17 @@ describe('repos:searchBaseRefs SSH relay', () => {
     expect(mockGitProvider.exec).not.toHaveBeenCalled()
   })
 
-  it('short-circuits an empty query without calling the relay', async () => {
+  it('returns refs for an empty query so remote Branch pickers open populated', async () => {
+    const stdout = [
+      'refs/remotes/origin/main\0origin/main',
+      'refs/remotes/upstream/feature-x\0upstream/feature-x'
+    ].join('\n')
+    mockGitProvider.exec = vi.fn().mockImplementation((argv: string[]) => {
+      if (argv[0] === 'remote') {
+        return Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' })
+      }
+      return Promise.resolve({ stdout, stderr: '' })
+    })
     mockStore.getRepo.mockReturnValue({
       id: 'r1',
       path: '/remote/repo',
@@ -1345,14 +1355,47 @@ describe('repos:searchBaseRefs SSH relay', () => {
       query: ''
     })
 
-    // Why: empty-query short-circuit must happen in the handler (mirrors the
-    // local path's normalizeRefSearchQuery guard). Without it a user-typed
-    // empty query would leak every ref from the remote.
-    expect(result).toEqual([])
-    expect(mockGitProvider.exec).not.toHaveBeenCalled()
+    expect(result).toEqual(['origin/main', 'upstream/feature-x'])
+    expect(mockGitProvider.exec).toHaveBeenCalledTimes(2)
+    const [argv] = mockGitProvider.exec.mock.calls.find(
+      (call) => (call[0] as string[])[0] === 'for-each-ref'
+    )!
+    expect(argv).toContain('--exclude=refs/remotes/**/HEAD')
+    expect(argv).toContain('--count=100')
+    expect(argv).toContain('refs/heads/**/**')
+    expect(argv).toContain('refs/heads/**/**/**')
+    expect(argv).toContain('refs/remotes/**/**')
+    expect(argv).toContain('refs/remotes/**/**/**')
   })
 
-  it('short-circuits a query containing only glob metacharacters', async () => {
+  it('sanitizes glob metacharacter-only queries into the empty-query branch list', async () => {
+    mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
+    mockStore.getRepo.mockReturnValue({
+      id: 'r1',
+      path: '/remote/repo',
+      connectionId: 'conn-1',
+      kind: 'git'
+    })
+
+    await handlers.get('repos:searchBaseRefs')!(null, {
+      repoId: 'r1',
+      query: '***'
+    })
+
+    // Why: normalizeRefSearchQuery still strips glob metacharacters before
+    // building argv; the resulting empty query now intentionally lists refs.
+    const [argv] = mockGitProvider.exec.mock.calls.find(
+      (call) => (call[0] as string[])[0] === 'for-each-ref'
+    )!
+    expect(argv).toContain('--exclude=refs/remotes/**/HEAD')
+    expect(argv).toContain('--count=100')
+    expect(argv).toContain('refs/heads/**/**')
+    expect(argv).toContain('refs/heads/**/**/**')
+    expect(argv).toContain('refs/remotes/**/**')
+    expect(argv).toContain('refs/remotes/**/**/**')
+  })
+
+  it('rejects invalid limits before building broad relay searches', async () => {
     mockStore.getRepo.mockReturnValue({
       id: 'r1',
       path: '/remote/repo',
@@ -1362,17 +1405,55 @@ describe('repos:searchBaseRefs SSH relay', () => {
 
     const result = await handlers.get('repos:searchBaseRefs')!(null, {
       repoId: 'r1',
-      query: '***'
+      query: '',
+      limit: 0.5
     })
 
-    // Why: normalizeRefSearchQuery strips `*?[]\`, so a query made up only of
-    // glob metacharacters normalizes to '' and must be treated like an empty
-    // query (no relay call, no leaked refs). Guards against glob injection
-    // via the SSH branch.
     expect(result).toEqual([])
     expect(mockGitProvider.exec).not.toHaveBeenCalled()
   })
 
+  it('retries without --exclude for older git on SSH hosts', async () => {
+    const stdout = [
+      'refs/remotes/origin/main\0origin/main',
+      'refs/remotes/origin/HEAD\0origin/HEAD'
+    ].join('\n')
+    mockGitProvider.exec = vi.fn().mockImplementation((argv: string[]) => {
+      if (argv[0] === 'remote') {
+        return Promise.resolve({ stdout: 'origin\n', stderr: '' })
+      }
+      if (argv.includes('--exclude=refs/remotes/**/HEAD')) {
+        return Promise.reject(
+          Object.assign(new Error("unknown option `exclude'"), {
+            stderr: "error: unknown option `exclude'"
+          })
+        )
+      }
+      return Promise.resolve({ stdout, stderr: '' })
+    })
+    mockStore.getRepo.mockReturnValue({
+      id: 'r1',
+      path: '/remote/repo',
+      connectionId: 'conn-1',
+      kind: 'git'
+    })
+
+    const result = await handlers.get('repos:searchBaseRefs')!(null, {
+      repoId: 'r1',
+      query: '',
+      limit: 1
+    })
+
+    expect(result).toEqual(['origin/main'])
+    const forEachRefCalls = mockGitProvider.exec.mock.calls.filter(
+      (call) => (call[0] as string[])[0] === 'for-each-ref'
+    )
+    expect(forEachRefCalls).toHaveLength(2)
+    expect(forEachRefCalls[0][0]).toContain('--exclude=refs/remotes/**/HEAD')
+    expect(forEachRefCalls[1][0]).not.toContain('--exclude=refs/remotes/**/HEAD')
+    expect(forEachRefCalls[1][0]).toContain('--count=104')
+  })
+
   it('sends the widened `**` argv so all remotes and slash-named branches are discoverable', async () => {
     // Why: this is the core issue-624 behavior — the SSH path must glob all
     // remotes, not just origin. The `**` globs additionally span ref segments
@@ -1390,7 +1471,9 @@ describe('repos:searchBaseRefs SSH relay', () => {
     await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', query: 'upstream' })
 
     expect(mockGitProvider.exec).toHaveBeenCalledTimes(2)
-    const [argv, path] = mockGitProvider.exec.mock.calls[0]
+    const [argv, path] = mockGitProvider.exec.mock.calls.find(
+      (call) => (call[0] as string[])[0] === 'for-each-ref'
+    )!
     expect(path).toBe('/remote/repo')
     expect(argv[0]).toBe('for-each-ref')
     expect(argv).toContain('refs/heads/**/*upstream*')
@@ -1399,7 +1482,7 @@ describe('repos:searchBaseRefs SSH relay', () => {
     expect(argv).toContain('refs/remotes/**/*upstream*/**')
     // Guard against regression to the old origin-only glob.
     expect(argv).not.toContain('refs/remotes/origin/*upstream*')
-    expect(mockGitProvider.exec.mock.calls[1]).toEqual([['remote'], '/remote/repo'])
+    expect(mockGitProvider.exec).toHaveBeenCalledWith(['remote'], '/remote/repo')
   })
 
   it('sends segmented argv for display-format queries like `upstream/main`', async () => {
@@ -1420,7 +1503,9 @@ describe('repos:searchBaseRefs SSH relay', () => {
     await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', query: 'upstream/main' })
 
     expect(mockGitProvider.exec).toHaveBeenCalledTimes(2)
-    const [argv] = mockGitProvider.exec.mock.calls[0]
+    const [argv] = mockGitProvider.exec.mock.calls.find(
+      (call) => (call[0] as string[])[0] === 'for-each-ref'
+    )!
     expect(argv).toContain('refs/remotes/*upstream*/*main*')
     expect(argv).toContain('refs/heads/*upstream*/*main*')
     // Regression guard: the literal slash must never appear inside a
@@ -1428,7 +1513,7 @@ describe('repos:searchBaseRefs SSH relay', () => {
     // which fnmatch cannot match because `*` doesn't cross `/`.
     expect(argv).not.toContain('refs/remotes/*upstream/main*/*')
     expect(argv).not.toContain('refs/remotes/*/*upstream/main*')
-    expect(mockGitProvider.exec.mock.calls[1]).toEqual([['remote'], '/remote/repo'])
+    expect(mockGitProvider.exec).toHaveBeenCalledWith(['remote'], '/remote/repo')
   })
 
   it('parses NUL-delimited stdout and filters /HEAD pseudo-refs', async () => {
diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts
index 66ec75eaf9f..148c00e4b84 100644
--- a/src/main/ipc/repos.ts
+++ b/src/main/ipc/repos.ts
@@ -50,6 +50,7 @@ import {
   parseRemoteCount,
   resolveDefaultBaseRefViaExec,
   buildSearchBaseRefsArgv,
+  isForEachRefExcludeUnsupportedError,
   searchBaseRefDetails
 } from '../git/repo'
 import { getSshGitProvider } from '../providers/ssh-git-dispatch'
@@ -1385,6 +1386,9 @@ async function searchBaseRefDetailsForRepo(
     return []
   }
   const limit = args.limit ?? 25
+  if (!Number.isInteger(limit) || limit <= 0) {
+    return []
+  }
   // Why: remote repos need the relay to list branches on the remote host.
   if (repo.connectionId) {
     const provider = getSshGitProvider(repo.connectionId)
@@ -1393,20 +1397,25 @@ async function searchBaseRefDetailsForRepo(
     }
     // Why: mirror the local path's sanitization (normalizeRefSearchQuery
     // in ../git/repo.ts) — strip glob metacharacters to prevent glob
-    // injection via the SSH branch, and short-circuit empty queries so
-    // we don't leak every ref. Without this the SSH path diverges from
-    // the local path's behavior.
+    // injection via the SSH branch while preserving empty-query branch lists.
     const normalizedQuery = normalizeRefSearchQuery(args.query)
-    if (!normalizedQuery) {
-      return []
-    }
     try {
       // Why: argv (including the two-remote-glob rationale) lives in
       // buildSearchBaseRefsArgv so the SSH and local paths cannot drift.
-      const [result, remotesResult] = await Promise.all([
-        provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path),
-        provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' }))
-      ])
+      const remotesPromise = provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' }))
+      let result: { stdout: string }
+      try {
+        result = await provider.exec(buildSearchBaseRefsArgv(normalizedQuery, limit), repo.path)
+      } catch (err) {
+        if (!isForEachRefExcludeUnsupportedError(err)) {
+          throw err
+        }
+        result = await provider.exec(
+          buildSearchBaseRefsArgv(normalizedQuery, limit, { excludeRemoteHead: false }),
+          repo.path
+        )
+      }
+      const remotesResult = await remotesPromise
       // Why: delegate the NUL-parse + HEAD filter + dedup + limit pipeline
       // to the shared helper so the SSH and local paths cannot diverge.
       // See parseAndFilterSearchRefs in ../git/repo.ts for the dedup +
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index 16e4031af99..6a389e00569 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -6134,6 +6134,118 @@ describe('OrcaRuntimeService', () => {
     await expect(runtime.searchRepoRefs('id:repo-1', 'main', -5)).rejects.toThrow('invalid_limit')
   })
 
+  it('returns capped SSH refs for empty runtime repo searches', async () => {
+    const remoteRepo = {
+      id: 'remote-repo',
+      path: '/home/user/repo',
+      displayName: 'remote',
+      badgeColor: 'blue',
+      addedAt: 1,
+      connectionId: 'ssh-1'
+    }
+    const runtimeStore = {
+      ...store,
+      getRepos: () => [remoteRepo],
+      getRepo: () => remoteRepo
+    }
+    const provider = {
+      exec: vi.fn().mockImplementation((argv: string[]) => {
+        if (argv[0] === 'remote') {
+          return Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' })
+        }
+        return Promise.resolve({
+          stdout: [
+            'refs/remotes/origin/main\0origin/main',
+            'refs/remotes/upstream/feature-x\0upstream/feature-x',
+            'refs/remotes/upstream/HEAD\0upstream/HEAD',
+            'refs/heads/local-only\0local-only'
+          ].join('\n'),
+          stderr: ''
+        })
+      })
+    }
+    registerSshGitProvider('ssh-1', provider as never)
+    const runtime = new OrcaRuntimeService(runtimeStore as never)
+
+    const result = await runtime.searchRepoRefs('id:remote-repo', '', 2)
+
+    expect(result).toEqual({
+      refs: ['origin/main', 'upstream/feature-x'],
+      refDetails: [
+        { refName: 'origin/main', localBranchName: 'main' },
+        { refName: 'upstream/feature-x', localBranchName: 'feature-x' }
+      ],
+      truncated: true
+    })
+    expect(provider.exec).toHaveBeenCalledWith(
+      expect.arrayContaining([
+        '--exclude=refs/remotes/**/HEAD',
+        '--count=12',
+        'refs/heads/**/**',
+        'refs/heads/**/**/**',
+        'refs/remotes/**/**',
+        'refs/remotes/**/**/**'
+      ]),
+      '/home/user/repo'
+    )
+    expect(provider.exec).toHaveBeenCalledWith(['remote'], '/home/user/repo')
+  })
+
+  it('retries runtime SSH ref searches without --exclude for older git hosts', async () => {
+    const remoteRepo = {
+      id: 'remote-repo',
+      path: '/home/user/repo',
+      displayName: 'remote',
+      badgeColor: 'blue',
+      addedAt: 1,
+      connectionId: 'ssh-1'
+    }
+    const runtimeStore = {
+      ...store,
+      getRepos: () => [remoteRepo],
+      getRepo: () => remoteRepo
+    }
+    const provider = {
+      exec: vi.fn().mockImplementation((argv: string[]) => {
+        if (argv[0] === 'remote') {
+          return Promise.resolve({ stdout: 'origin\n', stderr: '' })
+        }
+        if (argv.includes('--exclude=refs/remotes/**/HEAD')) {
+          return Promise.reject(
+            Object.assign(new Error("unknown option `exclude'"), {
+              stderr: "error: unknown option `exclude'"
+            })
+          )
+        }
+        return Promise.resolve({
+          stdout: [
+            'refs/remotes/origin/main\0origin/main',
+            'refs/remotes/origin/HEAD\0origin/HEAD',
+            'refs/remotes/origin/feature-x\0origin/feature-x'
+          ].join('\n'),
+          stderr: ''
+        })
+      })
+    }
+    registerSshGitProvider('ssh-1', provider as never)
+    const runtime = new OrcaRuntimeService(runtimeStore as never)
+
+    const result = await runtime.searchRepoRefs('id:remote-repo', '', 1)
+
+    expect(result).toEqual({
+      refs: ['origin/main'],
+      refDetails: [{ refName: 'origin/main', localBranchName: 'main' }],
+      truncated: true
+    })
+    const forEachRefCalls = provider.exec.mock.calls.filter(
+      (call) => (call[0] as string[])[0] === 'for-each-ref'
+    )
+    expect(forEachRefCalls).toHaveLength(2)
+    expect(forEachRefCalls[0][0]).toContain('--exclude=refs/remotes/**/HEAD')
+    expect(forEachRefCalls[1][0]).not.toContain('--exclude=refs/remotes/**/HEAD')
+    expect(forEachRefCalls[1][0]).toContain('--count=108')
+  })
+
   it('resolves SSH worktrees when manually updating lineage', async () => {
     const remoteRepo = {
       id: 'remote-repo',
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index f4806c941a1..976371284f8 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -306,6 +306,7 @@ import {
   parseRemoteCount,
   resolveDefaultBaseRefViaExec,
   buildSearchBaseRefsArgv,
+  isForEachRefExcludeUnsupportedError,
   getRemoteDrift,
   getRecentDriftSubjects
 } from '../git/repo'
@@ -5796,14 +5797,21 @@ export class OrcaRuntimeService {
       return []
     }
     const normalizedQuery = normalizeRefSearchQuery(query)
-    if (!normalizedQuery) {
-      return []
-    }
     try {
-      const [result, remotesResult] = await Promise.all([
-        provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path),
-        provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' }))
-      ])
+      const remotesPromise = provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' }))
+      let result: { stdout: string }
+      try {
+        result = await provider.exec(buildSearchBaseRefsArgv(normalizedQuery, limit), repo.path)
+      } catch (err) {
+        if (!isForEachRefExcludeUnsupportedError(err)) {
+          throw err
+        }
+        result = await provider.exec(
+          buildSearchBaseRefsArgv(normalizedQuery, limit, { excludeRemoteHead: false }),
+          repo.path
+        )
+      }
+      const remotesResult = await remotesPromise
       const remotes = remotesResult.stdout
         .split('\n')
         .map((line) => line.trim())
diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
index f3356ce7ab1..e6bbaa4bdad 100644
--- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
+++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
@@ -43,6 +43,14 @@ import { parseGitLabIssueOrMRLink } from '@/lib/gitlab-links'
 import { cn } from '@/lib/utils'
 import { LinearIcon } from '@/components/icons/LinearIcon'
 import { searchRuntimeRepoBaseRefDetails } from '@/runtime/runtime-repo-client'
+import {
+  buildSmartWorkspaceSourceRows,
+  getBranchSearchRequest,
+  getSmartWorkspaceEmptyHint,
+  getVisibleBranchResults,
+  type SmartNameMode,
+  type SmartWorkspaceSourceRow
+} from './smart-workspace-source-results'
 import { filterAvailableTaskProviders } from '../../../../shared/task-providers'
 import type {
   BaseRefSearchResult,
@@ -51,8 +59,6 @@ import type {
   LinearIssue
 } from '../../../../shared/types'
 
-type SmartNameMode = 'smart' | 'github' | 'gitlab' | 'branches' | 'linear' | 'text'
-
 // Why: GitLab MR list filter — Open / Merged / Closed / All — replaces
 // GitHub's search-DSL on the GitLab tab per the agreed scope.
 type MrStateFilter = 'opened' | 'merged' | 'closed' | 'all'
@@ -117,22 +123,7 @@ const MODES: {
   { id: 'text', label: 'Name', Icon: CaseSensitive }
 ]
 
-const emptyHintByMode: Record = {
-  smart: 'Start typing to create a name or find a source.',
-  github: 'Start typing to search GitHub PRs and issues.',
-  gitlab: 'Start typing to search GitLab MRs and issues.',
-  branches: 'Start typing to find a branch or create a new one.',
-  linear: 'Start typing to search Linear issues.',
-  text: ''
-}
-
-type RowEntry =
-  | { kind: 'use-name'; value: string; name: string }
-  | { kind: 'create-branch'; value: string; name: string }
-  | { kind: 'github'; value: string; item: GitHubWorkItem }
-  | { kind: 'gitlab'; value: string; item: GitLabWorkItem }
-  | { kind: 'branch'; value: string; refName: string; localBranchName: string }
-  | { kind: 'linear'; value: string; issue: LinearIssue }
+type RowEntry = SmartWorkspaceSourceRow
 
 export default function SmartWorkspaceNameField({
   repos,
@@ -192,6 +183,10 @@ export default function SmartWorkspaceNameField({
   const [githubItems, setGithubItems] = useState([])
   const [gitlabItems, setGitlabItems] = useState([])
   const [branches, setBranches] = useState([])
+  const [branchResultsSource, setBranchResultsSource] = useState<{
+    repoId: string
+    query: string
+  } | null>(null)
   const [linearIssues, setLinearIssues] = useState([])
   const [githubLoading, setGithubLoading] = useState(false)
   const [gitlabLoading, setGitlabLoading] = useState(false)
@@ -293,6 +288,7 @@ export default function SmartWorkspaceNameField({
     setGithubItems([])
     setGitlabItems([])
     setBranches([])
+    setBranchResultsSource(null)
     setLinearIssues([])
     setGithubLoading(false)
     setGitlabLoading(false)
@@ -322,7 +318,6 @@ export default function SmartWorkspaceNameField({
   )
   const parsedGhLink = useMemo(() => parseGitHubIssueOrPRLink(debouncedQuery), [debouncedQuery])
   const shouldQueryGithub = !textOnly && (mode === 'smart' || mode === 'github')
-  const shouldQueryBranches = !textOnly && (mode === 'smart' || mode === 'branches')
   const shouldQueryLinear = !textOnly && linearAvailable && (mode === 'smart' || mode === 'linear')
 
   useEffect(() => {
@@ -463,28 +458,49 @@ export default function SmartWorkspaceNameField({
     shouldQueryGithub
   ])
 
+  const branchSearchRequest = useMemo(
+    () =>
+      getBranchSearchRequest({
+        disabled,
+        textOnly,
+        mode,
+        selectedRepoId: selectedRepo?.id ?? null,
+        query: debouncedQuery,
+        limit: RESULT_LIMIT
+      }),
+    [debouncedQuery, disabled, mode, selectedRepo?.id, textOnly]
+  )
+
   useEffect(() => {
-    if (disabled || !shouldQueryBranches || !selectedRepo) {
+    if (!branchSearchRequest) {
       setBranches([])
+      setBranchResultsSource(null)
       setBranchesLoading(false)
       return
     }
     let stale = false
+    setBranches([])
+    setBranchResultsSource(null)
     setBranchesLoading(true)
     void searchRuntimeRepoBaseRefDetails(
       settings,
-      selectedRepo.id,
-      debouncedQuery.trim(),
-      RESULT_LIMIT
+      branchSearchRequest.repoId,
+      branchSearchRequest.query,
+      branchSearchRequest.limit
     )
       .then((results) => {
         if (!stale) {
           setBranches(results)
+          setBranchResultsSource({
+            repoId: branchSearchRequest.repoId,
+            query: branchSearchRequest.query
+          })
         }
       })
       .catch(() => {
         if (!stale) {
           setBranches([])
+          setBranchResultsSource(null)
         }
       })
       .finally(() => {
@@ -495,7 +511,7 @@ export default function SmartWorkspaceNameField({
     return () => {
       stale = true
     }
-  }, [debouncedQuery, disabled, selectedRepo, settings, shouldQueryBranches])
+  }, [branchSearchRequest, settings])
 
   useEffect(() => {
     if (disabled || !shouldQueryLinear || !linearStatus.connected) {
@@ -667,87 +683,39 @@ export default function SmartWorkspaceNameField({
     shouldQueryGitlab
   ])
 
-  const rows = useMemo(() => {
-    const trimmed = value.trim()
-    // Why: on the Branches tab the generic "Use … as workspace name" row
-    // reads as off-topic — the user is picking/creating a branch. Swap it
-    // for a branch-creation row that's pinned above existing-branch results
-    // (suppressed when an existing branch matches exactly so we don't offer
-    // to "create" something that already exists).
-    const branchExactMatch =
-      mode === 'branches' &&
-      trimmed.length > 0 &&
-      branches.some((branch) => branch.refName === trimmed || branch.localBranchName === trimmed)
-    // Why: the "Use … as workspace name" row only makes sense in Smart
-    // mode, where the user might be typing a free-form name. On dedicated
-    // source tabs (GitHub/Linear/Branches) it's off-topic — the user is
-    // there to pick (or, on Branches, create) a source.
-    const useNameRow: RowEntry | null =
-      trimmed && mode === 'smart'
-        ? { kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed }
-        : null
-    const createBranchRow: RowEntry | null =
-      trimmed && mode === 'branches' && !branchExactMatch
-        ? { kind: 'create-branch', value: `create-branch-${trimmed}`, name: trimmed }
-        : null
-    const nextRows: RowEntry[] = []
-    if (useNameRow) {
-      nextRows.push(useNameRow)
-    }
-    if (mode === 'text') {
-      return nextRows
-    }
-    if (mode === 'smart' || mode === 'github') {
-      nextRows.push(
-        ...githubItems.map((item) => ({
-          kind: 'github' as const,
-          value: `github-${item.type}-${item.number}`,
-          item
-        }))
-      )
-    }
-    if (gitlabAvailable && (mode === 'smart' || mode === 'gitlab')) {
-      nextRows.push(
-        ...gitlabItems.map((item) => ({
-          kind: 'gitlab' as const,
-          value: `gitlab-${item.type}-${item.number}`,
-          item
-        }))
-      )
-    }
-    if (mode === 'smart' || mode === 'branches') {
-      if (createBranchRow) {
-        nextRows.push(createBranchRow)
-      }
-      nextRows.push(
-        ...branches.map((branch) => ({
-          kind: 'branch' as const,
-          value: `branch-${branch.refName}`,
-          refName: branch.refName,
-          localBranchName: branch.localBranchName
-        }))
-      )
-    }
-    if (linearAvailable && (mode === 'smart' || mode === 'linear')) {
-      nextRows.push(
-        ...linearIssues.map((issue) => ({
-          kind: 'linear' as const,
-          value: `linear-${issue.id}`,
-          issue
-        }))
-      )
-    }
-    return nextRows.slice(0, RESULT_LIMIT + 1)
-  }, [
-    branches,
-    githubItems,
-    gitlabAvailable,
-    gitlabItems,
-    linearIssues,
-    linearAvailable,
-    mode,
-    value
-  ])
+  const rows = useMemo(
+    () =>
+      buildSmartWorkspaceSourceRows({
+        branches: getVisibleBranchResults({
+          branches,
+          mode,
+          resultRepoId: branchResultsSource?.repoId ?? null,
+          resultQuery: branchResultsSource?.query ?? null,
+          selectedRepoId: selectedRepo?.id ?? null,
+          value
+        }),
+        githubItems,
+        gitlabAvailable,
+        gitlabItems,
+        linearAvailable,
+        linearIssues,
+        mode,
+        resultLimit: RESULT_LIMIT,
+        value
+      }),
+    [
+      branches,
+      branchResultsSource,
+      githubItems,
+      gitlabAvailable,
+      gitlabItems,
+      linearAvailable,
+      linearIssues,
+      mode,
+      selectedRepo?.id,
+      value
+    ]
+  )
 
   // Why: source rows (GitHub/branches/Linear) are driven by debouncedQuery,
   // so they're stale until the user pauses typing for SEARCH_DEBOUNCE_MS.
@@ -915,8 +883,9 @@ export default function SmartWorkspaceNameField({
        {
-          setMode(next as SmartNameMode)
-          setOpen(!disabled && next !== 'text')
+          const nextMode = next as SmartNameMode
+          setMode(nextMode)
+          setOpen(!disabled && nextMode !== 'text')
           requestAnimationFrame(() => localInputRef.current?.focus({ preventScroll: true }))
         }}
         className="gap-0"
@@ -1166,7 +1135,7 @@ export default function SmartWorkspaceNameField({
                 
{mode === 'linear' && linearStatusChecked && !linearStatus.connected ? 'Connect Linear in Settings to search issues.' - : emptyHintByMode[mode]} + : getSmartWorkspaceEmptyHint(mode)}
) : ( diff --git a/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts b/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts new file mode 100644 index 00000000000..c2d02d7d995 --- /dev/null +++ b/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import { + buildSmartWorkspaceSourceRows, + getBranchSearchRequest, + getSmartWorkspaceEmptyHint, + getVisibleBranchResults +} from './smart-workspace-source-results' + +describe('Branch source results', () => { + it('requests empty-query branch results in Branch mode', () => { + expect( + getBranchSearchRequest({ + disabled: false, + textOnly: false, + mode: 'branches', + selectedRepoId: 'repo-1', + query: '', + limit: 12 + }) + ).toEqual({ repoId: 'repo-1', query: '', limit: 12 }) + }) + + it('keeps Smart mode in its start-typing state for an empty query', () => { + expect( + getBranchSearchRequest({ + disabled: false, + textOnly: false, + mode: 'smart', + selectedRepoId: 'repo-1', + query: '', + limit: 12 + }) + ).toBeNull() + }) + + it('hides stale branch rows when Smart mode input is cleared', () => { + const rows = buildSmartWorkspaceSourceRows({ + mode: 'smart', + value: '', + branches: [{ refName: 'origin/old-result', localBranchName: 'old-result' }], + githubItems: [], + gitlabItems: [], + linearIssues: [], + gitlabAvailable: false, + linearAvailable: false, + resultLimit: 12 + }) + + expect(rows).toEqual([]) + }) + + it('hides branch results from a stale Branch-mode query', () => { + expect( + getVisibleBranchResults({ + mode: 'branches', + value: '', + selectedRepoId: 'repo-1', + resultRepoId: 'repo-1', + resultQuery: 'feature', + branches: [{ refName: 'origin/feature', localBranchName: 'feature' }] + }) + ).toEqual([]) + }) + + it('keeps matching empty-query branch results visible in Branch mode', () => { + expect( + getVisibleBranchResults({ + mode: 'branches', + value: '', + selectedRepoId: 'repo-1', + resultRepoId: 'repo-1', + resultQuery: '', + branches: [{ refName: 'origin/main', localBranchName: 'main' }] + }) + ).toEqual([{ refName: 'origin/main', localBranchName: 'main' }]) + }) + + it('keeps returned branch rows visible before the user types', () => { + const rows = buildSmartWorkspaceSourceRows({ + mode: 'branches', + value: '', + branches: [ + { refName: 'main', localBranchName: 'main' }, + { refName: 'origin/feature/autofill', localBranchName: 'feature/autofill' } + ], + githubItems: [], + gitlabItems: [], + linearIssues: [], + gitlabAvailable: false, + linearAvailable: false, + resultLimit: 12 + }) + + expect(rows).toEqual([ + { kind: 'branch', value: 'branch-main', refName: 'main', localBranchName: 'main' }, + { + kind: 'branch', + value: 'branch-origin/feature/autofill', + refName: 'origin/feature/autofill', + localBranchName: 'feature/autofill' + } + ]) + }) + + it('describes empty Branch results after the empty-query search runs', () => { + expect(getSmartWorkspaceEmptyHint('branches')).toBe('No matching branches.') + }) +}) diff --git a/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts b/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts new file mode 100644 index 00000000000..6c3cb694050 --- /dev/null +++ b/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts @@ -0,0 +1,153 @@ +import type { + BaseRefSearchResult, + GitHubWorkItem, + GitLabWorkItem, + LinearIssue +} from '../../../../shared/types' + +export type SmartNameMode = 'smart' | 'github' | 'gitlab' | 'branches' | 'linear' | 'text' + +export type SmartWorkspaceSourceRow = + | { kind: 'use-name'; value: string; name: string } + | { kind: 'create-branch'; value: string; name: string } + | { kind: 'github'; value: string; item: GitHubWorkItem } + | { kind: 'gitlab'; value: string; item: GitLabWorkItem } + | { kind: 'branch'; value: string; refName: string; localBranchName: string } + | { kind: 'linear'; value: string; issue: LinearIssue } + +const EMPTY_HINT_BY_MODE: Record = { + smart: 'Start typing to create a name or find a source.', + github: 'Start typing to search GitHub PRs and issues.', + gitlab: 'Start typing to search GitLab MRs and issues.', + branches: 'No matching branches.', + linear: 'Start typing to search Linear issues.', + text: '' +} + +export function getSmartWorkspaceEmptyHint(mode: SmartNameMode): string { + return EMPTY_HINT_BY_MODE[mode] +} + +export function getBranchSearchRequest({ + disabled, + textOnly, + mode, + selectedRepoId, + query, + limit +}: { + disabled: boolean + textOnly: boolean + mode: SmartNameMode + selectedRepoId: string | null + query: string + limit: number +}): { repoId: string; query: string; limit: number } | null { + const trimmedQuery = query.trim() + const shouldSearchBranches = mode === 'branches' || (mode === 'smart' && trimmedQuery.length > 0) + if (disabled || textOnly || !selectedRepoId || !shouldSearchBranches) { + return null + } + return { repoId: selectedRepoId, query: trimmedQuery, limit } +} + +export function getVisibleBranchResults({ + branches, + mode, + resultRepoId, + resultQuery, + selectedRepoId, + value +}: { + branches: BaseRefSearchResult[] + mode: SmartNameMode + resultRepoId: string | null + resultQuery: string | null + selectedRepoId: string | null + value: string +}): BaseRefSearchResult[] { + const currentQuery = value.trim() + if (mode !== 'branches' && mode !== 'smart') { + return [] + } + if (!selectedRepoId || resultRepoId !== selectedRepoId || resultQuery !== currentQuery) { + return [] + } + return branches +} + +export function buildSmartWorkspaceSourceRows({ + branches, + githubItems, + gitlabAvailable, + gitlabItems, + linearAvailable, + linearIssues, + mode, + resultLimit, + value +}: { + branches: BaseRefSearchResult[] + githubItems: GitHubWorkItem[] + gitlabAvailable: boolean + gitlabItems: GitLabWorkItem[] + linearAvailable: boolean + linearIssues: LinearIssue[] + mode: SmartNameMode + resultLimit: number + value: string +}): SmartWorkspaceSourceRow[] { + const trimmed = value.trim() + const nextRows: SmartWorkspaceSourceRow[] = [] + if (trimmed && mode === 'smart') { + nextRows.push({ kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed }) + } + if (mode === 'text') { + return nextRows + } + if (mode === 'smart' || mode === 'github') { + nextRows.push( + ...githubItems.map((item) => ({ + kind: 'github' as const, + value: `github-${item.type}-${item.number}`, + item + })) + ) + } + if (gitlabAvailable && (mode === 'smart' || mode === 'gitlab')) { + nextRows.push( + ...gitlabItems.map((item) => ({ + kind: 'gitlab' as const, + value: `gitlab-${item.type}-${item.number}`, + item + })) + ) + } + const shouldShowBranches = mode === 'branches' || (mode === 'smart' && trimmed.length > 0) + if (shouldShowBranches) { + const branchExactMatch = branches.some( + (branch) => branch.refName === trimmed || branch.localBranchName === trimmed + ) + if (trimmed && mode === 'branches' && !branchExactMatch) { + nextRows.push({ kind: 'create-branch', value: `create-branch-${trimmed}`, name: trimmed }) + } + nextRows.push( + ...branches.map((branch) => ({ + kind: 'branch' as const, + value: `branch-${branch.refName}`, + refName: branch.refName, + localBranchName: branch.localBranchName + })) + ) + } + if (linearAvailable && (mode === 'smart' || mode === 'linear')) { + nextRows.push( + ...linearIssues.map((issue) => ({ + kind: 'linear' as const, + value: `linear-${issue.id}`, + issue + })) + ) + } + return nextRows.slice(0, resultLimit + 1) +} From 7242b416985b95bfe5e0713ba1e4faf14d320540 Mon Sep 17 00:00:00 2001 From: Jonathan Avila Date: Fri, 29 May 2026 20:22:41 -0400 Subject: [PATCH 039/224] Fix GitHub merge queue PR status (#2856) * Fix GitHub merge queue PR status * Fix merge queue GraphQL field encoding Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/main/github/client-issue-source.test.ts | 31 +- src/main/github/client.test.ts | 287 ++++++++++++++++-- src/main/github/client.ts | 256 ++++++++++++++-- src/main/ipc/github.test.ts | 28 ++ src/main/ipc/github.ts | 30 ++ src/main/runtime/orca-runtime.ts | 11 + src/main/runtime/rpc/methods/github.test.ts | 23 ++ src/main/runtime/rpc/methods/github.ts | 17 ++ src/main/runtime/runtime-rpc.ts | 1 + src/preload/api-types.ts | 7 + src/preload/index.ts | 9 + .../src/components/GitHubItemDialog.tsx | 80 ++--- .../src/components/PullRequestPage.tsx | 80 ++--- src/renderer/src/components/TaskPage.tsx | 121 +++----- .../components/github-pr-merge-state.test.ts | 76 +++++ .../src/components/github-pr-merge-state.ts | 196 ++++++++++++ .../components/right-sidebar/PRActions.tsx | 84 +++-- .../task-page-cache-selectors.test.ts | 21 ++ .../components/task-page-cache-selectors.ts | 2 + src/renderer/src/web/web-preload-api.test.ts | 7 + src/renderer/src/web/web-preload-api.ts | 5 + src/shared/hosted-review-github.ts | 15 + src/shared/hosted-review-queue.test.ts | 21 ++ src/shared/hosted-review-queue.ts | 12 + src/shared/hosted-review.ts | 7 +- src/shared/types.ts | 9 +- 26 files changed, 1214 insertions(+), 222 deletions(-) create mode 100644 src/renderer/src/components/github-pr-merge-state.test.ts create mode 100644 src/renderer/src/components/github-pr-merge-state.ts diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index c2eb7c042a7..d65c37841b6 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -278,9 +278,18 @@ describe('GitHub issue source split', () => { const item = await getWorkItem('/repo-root', 42, 'pr') expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'repos/fork/orca/pulls/42'], { - cwd: '/repo-root' - }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'pr', + 'view', + '42', + '--repo', + 'fork/orca', + '--json', + expect.stringContaining('reviewDecision') + ], + { cwd: '/repo-root' } + ) expect(item?.type).toBe('pr') }) @@ -311,9 +320,19 @@ describe('GitHub issue source split', () => { ['api', 'repos/stablyai/orca/issues/42'], { cwd: '/repo-root' } ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/fork/orca/pulls/42'], { - cwd: '/repo-root' - }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + [ + 'pr', + 'view', + '42', + '--repo', + 'fork/orca', + '--json', + expect.stringContaining('reviewDecision') + ], + { cwd: '/repo-root' } + ) expect(item?.type).toBe('pr') }) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index b6620d4f3b8..a2e720a362b 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -91,8 +91,10 @@ import { getPullRequestPushTarget, mergePR, resolveReviewThread, + setPRAutoMerge, updatePRTitle, - _resetOwnerRepoCache + _resetOwnerRepoCache, + _resetMergeQueueCacheForTests } from './client' describe('getPRForBranch', () => { @@ -120,6 +122,7 @@ describe('getPRForBranch', () => { releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() + _resetMergeQueueCacheForTests() }) it('queries GitHub by head branch when the remote is on github.com', async () => { @@ -230,7 +233,7 @@ describe('getPRForBranch', () => { '--repo', 'stablyai/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -243,7 +246,7 @@ describe('getPRForBranch', () => { '--repo', 'fork/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -282,7 +285,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -373,7 +376,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -464,7 +467,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -513,30 +516,48 @@ describe('getPRForBranch', () => { it('prefers branch lookup over a fallback PR number', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 43, + title: 'Branch PR wins', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/43', + updated_at: '2026-03-28T00:00:00Z', + draft: false, + mergeable: true, + head: { ref: 'feature/test', sha: 'branch-head-oid' }, + base: { ref: 'main', sha: 'branch-base-oid' } + } + ]) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ number: 43, - title: 'Branch PR wins', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/43', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - head: { ref: 'feature/test', sha: 'branch-head-oid' }, - base: { ref: 'main', sha: 'branch-base-oid' } - } - ]) - }) + title: 'Hydrated branch PR wins', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/43', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature/test', + baseRefOid: 'branch-base-oid', + headRefOid: 'branch-head-oid' + }) + }) const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 42) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, ['api', 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=1'], { cwd: '/repo-root' } ) - expect(pr).toMatchObject({ number: 43, title: 'Branch PR wins' }) + expect(pr).toMatchObject({ number: 43, title: 'Hydrated branch PR wins' }) }) it('uses a fallback PR number only after branch lookup misses', async () => { @@ -576,7 +597,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -613,13 +634,54 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(pr?.number).toBe(77) }) + it('normalizes exact linked PR fallback metadata when no GitHub remote is resolved', async () => { + getOwnerRepoMock.mockResolvedValueOnce(null) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 77, + title: 'Linked fallback PR', + state: 'OPEN', + url: 'https://example.com/pr/77', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: '', + autoMergeRequest: { enabledAt: '2026-03-28T00:00:00Z' }, + baseRefName: 'main', + headRefName: 'feature/test', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) + }) + + const pr = await getPRForBranch('/non-github-repo', 'feature/test', 77) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'pr', + 'view', + '77', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: '/non-github-repo' } + ) + expect(pr).toMatchObject({ + number: 77, + reviewDecision: null, + autoMergeEnabled: true + }) + expect(pr?.mergeQueueRequired).toBeUndefined() + }) + it('falls back to gh pr view when the remote cannot be resolved to GitHub', async () => { getOwnerRepoMock.mockResolvedValueOnce(null) ghExecFileAsyncMock.mockResolvedValueOnce({ @@ -647,7 +709,7 @@ describe('getPRForBranch', () => { 'view', 'feature/test', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/non-github-repo' } ) @@ -823,7 +885,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -871,7 +933,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -1039,6 +1101,7 @@ describe('GitHub GraphQL rate-limit guard', () => { releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() + _resetMergeQueueCacheForTests() }) it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => { @@ -1140,7 +1203,7 @@ describe('GitHub GraphQL rate-limit guard', () => { '--repo', 'stablyai/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -1159,6 +1222,149 @@ describe('GitHub GraphQL rate-limit guard', () => { ) }) + it('sets and disables PR auto-merge with explicit PR repos and SSH context', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await expect( + setPRAutoMerge('/remote/repo-root', 7, true, 'ssh-1', { + owner: 'stablyai', + repo: 'orca' + }) + ).resolves.toEqual({ ok: true }) + await expect( + setPRAutoMerge('/remote/repo-root', 7, false, 'ssh-1', { + owner: 'stablyai', + repo: 'orca' + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + ['pr', 'merge', '7', '--auto', '--repo', 'stablyai/orca'], + expect.objectContaining({ + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + }) + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'], + expect.objectContaining({ + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + }) + ) + expect(ghExecFileAsyncMock.mock.calls[0]?.[1]).not.toHaveProperty('cwd') + }) + + it('blocks direct merge when GitHub reports required approval', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'REVIEW_REQUIRED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) + }) + + await expect( + mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + ).resolves.toEqual({ + ok: false, + error: 'This pull request requires review approval before it can be merged.' + }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toContain('graphql') + }) + + it('detects merge queues once per base branch and blocks direct merges', async () => { + const prView = { + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'true', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + } + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } }) + }) + .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + + await expect( + mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + ).resolves.toEqual({ + ok: false, + error: + 'This pull request must be merged through GitHub merge queue. Use Merge when ready instead.' + }) + await expect( + mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + ).resolves.toMatchObject({ ok: false }) + + expect( + ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql')) + ).toHaveLength(1) + expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toEqual( + expect.arrayContaining(['-f', 'owner=stablyai', '-f', 'repo=orca', '-f', 'branch=true']) + ) + expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).not.toContain('-F') + }) + + it('caches unknown merge queue probes after GraphQL failures', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + const prView = { + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + } + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + .mockRejectedValueOnce(new Error('network is down')) + .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + + await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({ + mergeQueueRequired: null + }) + await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({ + mergeQueueRequired: null + }) + + expect( + ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql')) + ).toHaveLength(1) + }) + it('returns conflicting file details instead of running gh merge when PR is dirty', async () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ @@ -1197,14 +1403,31 @@ describe('GitHub GraphQL rate-limit guard', () => { }) it('does not run merge conflict preflight for SSH-backed repos', async () => { - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'CONFLICTING', + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) + }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect( mergePR('/remote/repo-root', 7, 'squash', 'ssh-1', { owner: 'stablyai', repo: 'orca' }) ).resolves.toEqual({ ok: true }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index e9cb2177af7..fb5c5be0a3b 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -9,6 +9,7 @@ import type { PRConflictSummary, PRRefreshOutcome, PRMergeableState, + PRReviewDecision, PRCheckDetail, PRCheckRunDetails, GitHubCommentResult, @@ -83,6 +84,13 @@ import { type GhExecOptions = ReturnType const ORCA_REPO = 'stablyai/orca' +const MERGE_QUEUE_CACHE_TTL_MS = 10 * 60 * 1000 +const MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS = 60 * 1000 +const mergeQueueRequiredCache = new Map() + +export function _resetMergeQueueCacheForTests(): void { + mergeQueueRequiredCache.clear() +} async function assertRateLimitBudget(bucket: RateLimitBucketKind): Promise { await getRateLimit() @@ -346,7 +354,7 @@ const WORK_ITEM_PR_LIST_JSON_FIELDS = // GraphQL work across every row. Requested reviewers are kept in the list // payload because the Tasks table renders that column on first paint. const WORK_ITEM_PR_DETAIL_JSON_FIELDS = - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,maintainerCanModify' + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,autoMergeRequest,maintainerCanModify' function mapIssueWorkItem(item: Record): MainWorkItem { return { @@ -509,6 +517,16 @@ function normalizePRMergeable(value: unknown): PRMergeableState | undefined { return undefined } +function normalizeReviewDecision(value: unknown): PRReviewDecision | null { + return value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED' + ? value + : null +} + +function isAutoMergeEnabled(value: unknown): boolean { + return typeof value === 'object' && value !== null +} + function checkRollupEntries(value: unknown): unknown[] { if (Array.isArray(value)) { return value @@ -637,7 +655,7 @@ function mapPullRequestWorkItem( ...(deletions !== undefined ? { deletions } : {}), ...(changedFiles !== undefined ? { changedFiles } : {}), ...('reviewDecision' in item - ? { reviewDecision: typeof item.reviewDecision === 'string' ? item.reviewDecision : null } + ? { reviewDecision: normalizeReviewDecision(item.reviewDecision) } : {}), ...(item.reviewRequests !== undefined || item.requested_reviewers !== undefined ? { reviewRequests: usersFromUnknown(item.reviewRequests ?? item.requested_reviewers) } @@ -650,6 +668,9 @@ function mapPullRequestWorkItem( ? { checksSummary: deriveWorkItemCheckSummary(item.statusCheckRollup) } : {}), ...(mergeable ? { mergeable } : {}), + ...('autoMergeRequest' in item + ? { autoMergeEnabled: isAutoMergeEnabled(item.autoMergeRequest) } + : {}), ...('mergeStateStatus' in item ? { mergeStateStatus: typeof item.mergeStateStatus === 'string' ? item.mergeStateStatus : null @@ -696,11 +717,31 @@ async function fetchPullRequestWorkItem( ): Promise { const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) if (ownerRepo) { - const { stdout } = await ghExecFileAsync( - ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], - ghOptions - ) - return mapPullRequestWorkItem(JSON.parse(stdout) as Record, ownerRepo) + try { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'view', + String(number), + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--json', + WORK_ITEM_PR_DETAIL_JSON_FIELDS + ], + ghOptions + ) + const item = JSON.parse(stdout) as Record + const mapped = mapPullRequestWorkItem(item, ownerRepo) + const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined + const mergeQueueRequired = await detectMergeQueueRequired(ownerRepo, baseRefName, ghOptions) + return { ...mapped, mergeQueueRequired } + } catch { + const { stdout } = await ghExecFileAsync( + ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], + ghOptions + ) + return mapPullRequestWorkItem(JSON.parse(stdout) as Record, ownerRepo) + } } const { stdout } = await ghExecFileAsync( @@ -1647,6 +1688,11 @@ type PullRequestLookupData = { updatedAt: string isDraft?: boolean mergeable: string + reviewDecision?: PRReviewDecision | null + autoMergeRequest?: unknown + autoMergeEnabled?: boolean + mergeQueueRequired?: boolean | null + mergeStateStatus?: string | null baseRefName?: string headRefName?: string baseRefOid?: string @@ -1669,6 +1715,8 @@ type RestPullRequest = { } const PR_LOOKUP_JSON_FIELDS = + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' +const PR_BRANCH_LIST_JSON_FIELDS = 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' function mapRestPRMergeable(pr: RestPullRequest): PRMergeableState { @@ -1699,6 +1747,108 @@ function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { } } +function normalizePullRequestLookupData(data: PullRequestLookupData): PullRequestLookupData { + return { + ...data, + reviewDecision: + data.reviewDecision !== undefined ? normalizeReviewDecision(data.reviewDecision) : undefined, + autoMergeEnabled: + data.autoMergeEnabled ?? + ('autoMergeRequest' in data ? isAutoMergeEnabled(data.autoMergeRequest) : undefined) + } +} + +function cacheMergeQueueRequired(cacheKey: string, value: boolean | null, ttlMs: number): void { + mergeQueueRequiredCache.set(cacheKey, { + value, + expiresAt: Date.now() + ttlMs + }) +} + +async function detectMergeQueueRequired( + ownerRepo: OwnerRepo, + branchName: string | undefined, + ghOptions: GhExecOptions +): Promise { + if (!branchName) { + return null + } + const cacheKey = `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}:${branchName}` + const cached = mergeQueueRequiredCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) { + return cached.value + } + const guard = rateLimitGuard('graphql') + if (guard.blocked) { + return null + } + const query = `query($owner: String!, $repo: String!, $branch: String!) { + repository(owner: $owner, name: $repo) { + mergeQueue(branch: $branch) { id } + } + }` + try { + noteRateLimitSpend('graphql') + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${query}`, + '-f', + `owner=${ownerRepo.owner}`, + '-f', + `repo=${ownerRepo.repo}`, + '-f', + `branch=${branchName}` + ], + ghOptions + ) + const parsed = JSON.parse(stdout) as { + data?: { repository?: { mergeQueue?: { id?: unknown } | null } | null } + } + const value = Boolean(parsed.data?.repository?.mergeQueue) + cacheMergeQueueRequired(cacheKey, value, MERGE_QUEUE_CACHE_TTL_MS) + return value + } catch { + // Why: failed merge-queue probes should stay conservative without + // retrying GraphQL on every status poll while GitHub/network is unhappy. + cacheMergeQueueRequired(cacheKey, null, MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS) + return null + } +} + +async function hydratePullRequestLookupData( + ownerRepo: OwnerRepo, + data: PullRequestLookupData, + ghOptions: GhExecOptions +): Promise { + const normalized = normalizePullRequestLookupData(data) + const hasRichMergeFields = + 'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data + return { + ...normalized, + mergeQueueRequired: hasRichMergeFields + ? await detectMergeQueueRequired(ownerRepo, normalized.baseRefName, ghOptions) + : undefined + } +} + +async function hydrateBranchLookupWithExactPR( + ownerRepo: OwnerRepo, + branchData: PullRequestLookupData | null, + ghOptions: GhExecOptions +): Promise { + if (!branchData) { + return null + } + try { + return (await getPRByNumber(ownerRepo, branchData.number, ghOptions)) ?? branchData + } catch { + return branchData + } +} + async function getRestPRForBranch( prRepo: OwnerRepo, headOwner: string, @@ -1733,7 +1883,7 @@ async function getFallbackPRListForBranch( '--limit', '1', '--json', - PR_LOOKUP_JSON_FIELDS + PR_BRANCH_LIST_JSON_FIELDS ], ghOptions ) @@ -1771,7 +1921,11 @@ async function getPRByNumber( ], ghOptions ) - return JSON.parse(stdout) as PullRequestLookupData + return hydratePullRequestLookupData( + ownerRepo, + JSON.parse(stdout) as PullRequestLookupData, + ghOptions + ) } catch (err) { // Why: deleted or manually edited linked PR metadata should fall back to // branch discovery; quota/auth/network failures get one cheaper REST exact lookup. @@ -1779,7 +1933,8 @@ async function getPRByNumber( return null } try { - return await getRestPRByNumber(ownerRepo, number, ghOptions) + const restData = await getRestPRByNumber(ownerRepo, number, ghOptions) + return restData ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions) : null } catch (restErr) { if (isNotFoundGhError(restErr)) { return null @@ -1821,7 +1976,10 @@ async function lookupPRByNumber(args: { ['pr', 'view', String(args.number), '--json', PR_LOOKUP_JSON_FIELDS], args.ghOptions ) - return { data: JSON.parse(stdout), dataRepo: null } + return { + data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData), + dataRepo: null + } } catch (err) { if (isNoPullRequestError(err)) { // Why: stale cached fallback numbers should not turn every poll into an @@ -1908,9 +2066,12 @@ export async function getPRForBranchOutcome( if (candidates.length > 0) { for (const candidate of candidates) { try { - data = headRepo + const branchData = headRepo ? await getRestPRForBranch(candidate, headRepo.owner, branchName, ghOptions) : await getFallbackPRListForBranch(candidate, branchName, ghOptions) + // Why: REST/list branch lookup identifies the PR cheaply; exact + // `gh pr view` carries review, merge queue, and auto-merge state. + data = await hydrateBranchLookupWithExactPR(candidate, branchData, ghOptions) if (data) { dataRepo = candidate break @@ -1919,7 +2080,13 @@ export async function getPRForBranchOutcome( if (headRepo) { throw err } else { - data = await getRestPRForBranch(candidate, candidate.owner, branchName, ghOptions) + const branchData = await getRestPRForBranch( + candidate, + candidate.owner, + branchName, + ghOptions + ) + data = await hydrateBranchLookupWithExactPR(candidate, branchData, ghOptions) if (data) { dataRepo = candidate break @@ -1933,7 +2100,7 @@ export async function getPRForBranchOutcome( ['pr', 'view', branchName, '--json', PR_LOOKUP_JSON_FIELDS], ghOptions ) - data = JSON.parse(stdout) + data = normalizePullRequestLookupData(JSON.parse(stdout)) } catch (err) { if (isNoPullRequestError(err)) { data = null @@ -1976,6 +2143,12 @@ export async function getPRForBranchOutcome( checksStatus: deriveCheckStatus(data.statusCheckRollup), updatedAt: data.updatedAt, mergeable: (data.mergeable as PRMergeableState) ?? 'UNKNOWN', + ...(data.reviewDecision !== undefined ? { reviewDecision: data.reviewDecision } : {}), + ...(data.autoMergeEnabled !== undefined ? { autoMergeEnabled: data.autoMergeEnabled } : {}), + ...(data.mergeQueueRequired !== undefined + ? { mergeQueueRequired: data.mergeQueueRequired } + : {}), + ...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}), headSha: data.headRefOid, prRepo: dataRepo ?? undefined, headRepo: headRepo ?? undefined, @@ -2889,6 +3062,35 @@ export async function mergePR( } } +export async function setPRAutoMerge( + repoPath: string, + prNumber: number, + enabled: boolean, + connectionId?: string | null, + prRepo?: OwnerRepo | null +): Promise<{ ok: true } | { ok: false; error: string }> { + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId)) + await acquire() + try { + const args = ['pr', 'merge', String(prNumber), enabled ? '--auto' : '--disable-auto'] + if (ownerRepo) { + args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } + await ghExecFileAsync(args, { + ...ghOptions, + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + return { ok: true } + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' + return { ok: false, error: classifyGhError(message).message } + } finally { + release() + } +} + async function getPRMergeBlocker( repoPath: string, prNumber: number, @@ -2896,15 +3098,33 @@ async function getPRMergeBlocker( ghOptions: GhExecOptions, connectionId?: string | null ): Promise { - // Why: conflict summaries shell out to local git; SSH repo paths are remote-only - // until that helper is routed through the SSH git provider. - if (!ownerRepo || connectionId) { + if (!ownerRepo) { return null } try { const pr = await getPRByNumber(ownerRepo, prNumber, ghOptions) - if (pr?.mergeable !== 'CONFLICTING' || !pr.baseRefName || !pr.baseRefOid || !pr.headRefOid) { + if (!pr) { + return null + } + if (pr.reviewDecision === 'REVIEW_REQUIRED') { + return 'This pull request requires review approval before it can be merged.' + } + if (pr.reviewDecision === 'CHANGES_REQUESTED') { + return 'This pull request has requested changes and cannot be merged yet.' + } + if (pr.mergeQueueRequired === true) { + return 'This pull request must be merged through GitHub merge queue. Use Merge when ready instead.' + } + // Why: conflict summaries shell out to local git; SSH repo paths are remote-only + // until that helper is routed through the SSH git provider. + if ( + connectionId || + pr.mergeable !== 'CONFLICTING' || + !pr.baseRefName || + !pr.baseRefOid || + !pr.headRefOid + ) { return null } diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index c527c805ee4..2a5896dc0cc 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: GitHub IPC tests share one mocked Electron +handler harness; keeping the related route wiring together avoids duplicated setup. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -8,6 +10,7 @@ const { listWorkItemsMock, getAuthenticatedViewerMock, mergePRMock, + setPRAutoMergeMock, checkOrcaStarredMock, starOrcaMock, trackMock, @@ -21,6 +24,7 @@ const { listWorkItemsMock: vi.fn(), getAuthenticatedViewerMock: vi.fn(), mergePRMock: vi.fn(), + setPRAutoMergeMock: vi.fn(), checkOrcaStarredMock: vi.fn(), starOrcaMock: vi.fn(), trackMock: vi.fn(), @@ -44,6 +48,7 @@ vi.mock('../github/client', () => ({ listWorkItems: listWorkItemsMock, getAuthenticatedViewer: getAuthenticatedViewerMock, mergePR: mergePRMock, + setPRAutoMerge: setPRAutoMergeMock, checkOrcaStarred: checkOrcaStarredMock, starOrca: starOrcaMock })) @@ -88,6 +93,7 @@ describe('registerGitHubHandlers', () => { listWorkItemsMock.mockReset() getAuthenticatedViewerMock.mockReset() mergePRMock.mockReset() + setPRAutoMergeMock.mockReset() checkOrcaStarredMock.mockReset() starOrcaMock.mockReset() trackMock.mockReset() @@ -282,6 +288,28 @@ describe('registerGitHubHandlers', () => { }) }) + it('threads SSH connectionId through pull request auto-merge', async () => { + repos[0].connectionId = 'openclaw-2' + setPRAutoMergeMock.mockResolvedValue({ ok: true }) + + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:setPRAutoMerge']( + { sender: { id: 1 } }, + { + repoPath: '/workspace/repo', + prNumber: 42, + enabled: true, + prRepo: { owner: 'acme', repo: 'orca' } + } + ) + + expect(setPRAutoMergeMock).toHaveBeenCalledWith('/workspace/repo', 42, true, 'openclaw-2', { + owner: 'acme', + repo: 'orca' + }) + }) + it('forwards the authenticated viewer lookup', async () => { getAuthenticatedViewerMock.mockResolvedValue({ login: 'octocat', email: 'octocat@example.com' }) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 4eb3ab744bd..4a7fe5cb36c 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -39,6 +39,7 @@ import { addPRReviewCommentReply, updatePRTitle, mergePR, + setPRAutoMerge, updatePRState, rerunPRChecks, requestPRReviewers, @@ -683,6 +684,35 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) + ipcMain.handle( + 'gh:setPRAutoMerge', + async ( + event, + args: { + repoPath: string + prNumber: number + enabled: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredRepo(args, store) + const result = await setPRAutoMerge( + repo.path, + args.prNumber, + args.enabled, + repoConnectionId(repo), + args.prRepo ?? null + ) + if (result.ok) { + broadcastWorkItemMutated( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result + } + ) + ipcMain.handle( 'gh:updatePRState', async ( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 976371284f8..ba53880d2e7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -170,6 +170,7 @@ import { updatePRTitle, updatePRDetails, mergePR, + setPRAutoMerge, updatePRState, requestPRReviewers, removePRReviewers, @@ -6424,6 +6425,16 @@ export class OrcaRuntimeService { return mergePR(repo.path, prNumber, method, repo.connectionId ?? null, prRepo ?? null) } + async setRepoPRAutoMerge( + repoSelector: string, + prNumber: number, + enabled: boolean, + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return setPRAutoMerge(repo.path, prNumber, enabled, repo.connectionId ?? null, prRepo ?? null) + } + async updateRepoPRState( repoSelector: string, prNumber: number, diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index b7223989df4..37bf218a10f 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -371,6 +371,29 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) + it('sets PR auto-merge on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + setRepoPRAutoMerge: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.setPRAutoMerge', { + repo: 'repo-1', + prNumber: 7, + enabled: true, + prRepo: { owner: 'acme', repo: 'widgets' } + }) + ) + + expect(runtime.setRepoPRAutoMerge).toHaveBeenCalledWith('repo-1', 7, true, { + owner: 'acme', + repo: 'widgets' + }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + it('routes PR reviewer mutations on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index ebccf77bca0..ed48d4c6a53 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -123,6 +123,12 @@ const MergePr = RepoSelector.extend({ prRepo: SlugRepo.nullable().optional() }) +const SetPrAutoMerge = RepoSelector.extend({ + prNumber: z.number().int().positive(), + enabled: z.boolean(), + prRepo: SlugRepo.nullable().optional() +}) + const UpdatePrState = RepoSelector.extend({ prNumber: z.number().int().positive(), updates: z.object({ @@ -440,6 +446,17 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.mergeRepoPR(params.repo, params.prNumber, params.method, params.prRepo ?? null) }), + defineMethod({ + name: 'github.setPRAutoMerge', + params: SetPrAutoMerge, + handler: async (params, { runtime }) => + runtime.setRepoPRAutoMerge( + params.repo, + params.prNumber, + params.enabled, + params.prRepo ?? null + ) + }), defineMethod({ name: 'github.updatePRState', params: UpdatePrState, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 8cc6486c641..da35dd97ff0 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -169,6 +169,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'github.listLabels', 'github.listWorkItems', 'github.mergePR', + 'github.setPRAutoMerge', 'github.requestPRReviewers', 'github.project.listAccessible', 'github.project.listAssignableUsersBySlug', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c9fc9ca9b39..6081437dda9 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1005,6 +1005,13 @@ export type PreloadApi = { method?: 'merge' | 'squash' | 'rebase' prRepo?: GitHubOwnerRepo | null }) => Promise<{ ok: true } | { ok: false; error: string }> + setPRAutoMerge: (args: { + repoPath: string + repoId?: string + prNumber: number + enabled: boolean + prRepo?: GitHubOwnerRepo | null + }) => Promise<{ ok: true } | { ok: false; error: string }> updatePRState: (args: { repoPath: string repoId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 1b52641f5f9..589bb6e41f3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1074,6 +1074,15 @@ const api = { }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args), + setPRAutoMerge: (args: { + repoPath: string + repoId?: string + prNumber: number + enabled: boolean + prRepo?: { owner: string; repo: string } | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:setPRAutoMerge', args), + updatePRState: (args: { repoPath: string repoId?: string diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 0df1667fd69..fb2bc93614b 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -61,6 +61,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' @@ -107,6 +108,7 @@ import { getGitHubPRReviewerRows, normalizeGitHubReviewerLogins } from '@/components/github-pr-reviewer-display' +import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection' import { getConnectionId } from '@/lib/connection-context' @@ -365,31 +367,6 @@ function getStateTone(item: GitHubWorkItem): string { return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' } -function getPRMergeTooltip(item: GitHubWorkItem): string { - if (item.state === 'merged') { - return 'This pull request is already merged' - } - if (item.state === 'closed') { - return 'This pull request is closed' - } - if (item.mergeable === undefined && item.mergeStateStatus === undefined) { - return 'Merge status is unavailable for this PR' - } - if (item.mergeable === 'CONFLICTING') { - return 'GitHub reports merge conflicts' - } - if (item.mergeStateStatus === 'BEHIND') { - return 'Update the branch before merging' - } - if (item.mergeStateStatus === 'BLOCKED') { - return 'GitHub reports this pull request is blocked' - } - if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { - return 'GitHub says this PR can merge' - } - return 'GitHub has not reported a final merge status' -} - function WorkItemStateBadge({ item, className @@ -2830,14 +2807,10 @@ function PRActionsPanel({ const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) const confirm = useConfirmationDialog() const actionItem = { ...item, state: localState } + const mergePresentation = presentGitHubPRMergeState(actionItem) const canMutateState = localState !== 'merged' && (!!repoPath || !!projectOrigin) const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed' - const mergeDisabled = - !repoPath || - mergePending || - localState === 'closed' || - localState === 'merged' || - item.mergeable === 'CONFLICTING' + const mergeDisabled = !repoPath || mergePending || !mergePresentation.directMergeAvailable const patchProjectRowIfNeeded = useCallback( (state: GitHubWorkItem['state']) => { @@ -2916,7 +2889,8 @@ function PRActionsPanel({ repoPath, repoId: repoId ?? undefined, prNumber: item.number, - method + method, + prRepo: item.prRepo ?? null }) if (!result.ok) { toast.error(result.error) @@ -2932,6 +2906,33 @@ function PRActionsPanel({ } } + const handleAutoMerge = async (): Promise => { + if (!repoPath || !mergePresentation.autoMergeAction) { + return + } + const enabled = mergePresentation.autoMergeAction.kind === 'enable' + setMergePending(true) + try { + const result = await window.api.gh.setPRAutoMerge({ + repoPath, + repoId: repoId ?? undefined, + prNumber: item.number, + enabled, + prRepo: item.prRepo ?? null + }) + if (!result.ok) { + toast.error(result.error) + return + } + toast.success(enabled ? 'Auto-merge enabled' : 'Auto-merge disabled') + onMutated() + } catch { + toast.error(enabled ? 'Failed to enable auto-merge' : 'Failed to disable auto-merge') + } finally { + setMergePending(false) + } + } + return (