From ac830689c4bef0d5000936728d2a406fd59fa53d Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:47:28 +0900 Subject: [PATCH 001/271] fix(composer): autosize Create Worktree note on PR prefill (#10580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(composer): autosize Create Worktree note on PR prefill The Note textarea only grew on onInput, so programmatic PR title prefills left the box at one row with overflow hidden. Resize whenever the note value changes and allow scroll under max-height (fixes #10575). * review: size the composer note with field-sizing instead of a measure pass The prefill bug is the failure mode of imperative sizing: the height is only recomputed at the events someone remembered to hook, so a programmatic setNote (and a pane resize, and a font reflow) leaves it stale. Let the layout engine own the height, matching NativeChatComposerField and LinearIssueTextEditor. Drops the extracted helper and its unit test — that test asserted the two assignments it wrote and stayed green with the bug present. The composer test now pins the class contract and goes red on the pre-fix markup. --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../NewWorkspaceComposerCard.test.tsx | 39 +++++++++++++++++++ .../components/NewWorkspaceComposerCard.tsx | 11 ++---- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 16a7b82beae..33fc740980b 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -871,3 +871,42 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { expect(recipeChanges).toEqual([null]) }) }) + +describe('NewWorkspaceComposerCard note sizing', () => { + // Sizing is layout-driven (field-sizing) rather than a JS measure pass, and happy-dom + // has no layout engine, so these assert the class contract that produces the growth. + afterEach(() => { + act(() => current?.root.unmount()) + current?.container.remove() + current = null + }) + + function findNoteTextarea(container: HTMLElement): HTMLTextAreaElement { + const label = [...container.querySelectorAll('label')].find( + (candidate) => candidate.textContent?.trim() === 'Note' + ) + const textarea = label?.parentElement?.querySelector('textarea') + expect(textarea).toBeTruthy() + return textarea as HTMLTextAreaElement + } + + it('sizes from the note value, so a PR prefill written straight to state still shows in full', () => { + // #10575: the prefill never fires an input event, so nothing but the value can drive height. + current = renderCard({ + advancedOpen: true, + note: `PR #10575 — ${'a note title long enough to wrap over several lines '.repeat(3)}` + }) + + expect(findNoteTextarea(current.container).className).toContain('[field-sizing:content]') + }) + + it('keeps a note past the height cap readable instead of clipping it', () => { + current = renderCard({ advancedOpen: true, note: 'a'.repeat(4000) }) + + const { className } = findNoteTextarea(current.container) + expect(className).toContain('max-h-40') + expect(className).toContain('overflow-y-auto') + expect(className).toContain('scrollbar-sleek') + expect(className).not.toContain('overflow-hidden') + }) +}) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index f01350a6df9..478494916dd 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -1001,18 +1001,15 @@ export default function NewWorkspaceComposerCard({ value={note} onChange={(event) => onNoteChange(event.target.value)} onPaste={handleNotePaste} - onInput={(event) => { - // Why: reset then size to content so short notes stay compact and long ones grow without a scrollbar until max-h clamps. - const ta = event.currentTarget - ta.style.height = 'auto' - ta.style.height = `${ta.scrollHeight}px` - }} placeholder={translate( 'auto.components.NewWorkspaceComposerCard.090cfedeb4', 'Write a note' )} rows={1} - className="w-full min-w-0 resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 max-h-40" + // Why (#10575): field-sizing:content grows the note with its value, so a PR/MR + // prefill written straight to state sizes like typed text — an onInput measure + // pass never saw it. Past the max-h clamp the sleek scrollbar keeps it readable. + className="w-full min-w-0 resize-none overflow-y-auto scrollbar-sleek rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [field-sizing:content] max-h-40" /> From b2e29a22474b725e5e0f5679dcb5a6296bb8dc2d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:14:39 -0700 Subject: [PATCH 002/271] Fix Ctrl+Z in Windows and Linux terminals (#13398) * fix(terminal): preserve Windows undo chords * fix(terminal): keep undo menu hints unregistered --- src/main/menu/register-app-menu.test.ts | 20 ++++++++++++++++++++ src/main/menu/register-app-menu.ts | 8 ++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index 82d1bc184de..08b5a489a9c 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -259,6 +259,26 @@ describe('registerAppMenu', () => { } ) + it.each(['darwin', 'linux', 'win32'] as const)( + 'preserves terminal undo and redo chords on %s', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + registerAppMenu(buildMenuOptions()) + + const editSubmenu = getSubmenu(getTemplate(), 'Edit') + const expectedRegistration = platform === 'darwin' ? undefined : false + const undoItem = editSubmenu.find((item) => item.role === 'undo') + const redoItem = editSubmenu.find((item) => item.role === 'redo') + + expect(undoItem?.accelerator).toBeUndefined() + expect(redoItem?.accelerator).toBeUndefined() + expect(undoItem && 'registerAccelerator' in undoItem).toBe(platform !== 'darwin') + expect(redoItem && 'registerAccelerator' in redoItem).toBe(platform !== 'darwin') + expect(undoItem?.registerAccelerator).toBe(expectedRegistration) + expect(redoItem?.registerAccelerator).toBe(expectedRegistration) + } + ) + it('keeps selection actions native in a focused guest webview', () => { const send = vi.fn() const guestContents = { copy: vi.fn(), selectAll: vi.fn() } diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index 2feeef9fe47..cc99c69cbc7 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -167,11 +167,15 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { ] } + // Why: keep native menu hints while letting non-macOS Ctrl+Z/Ctrl+Y reach the focused terminal or DOM control. + const undoRedoOptions: Electron.MenuItemConstructorOptions = isMac + ? {} + : { registerAccelerator: false } const editMenu: Electron.MenuItemConstructorOptions = { label: translateMain('menu.edit', 'Edit'), submenu: [ - { role: 'undo' }, - { role: 'redo' }, + { role: 'undo', ...undoRedoOptions }, + { role: 'redo', ...undoRedoOptions }, { type: 'separator' }, { role: 'cut' }, createAppMenuSelectionItem({ From ced2719b26a101440c5bca3e96aa6c5a6aae7383 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:29:32 -0700 Subject: [PATCH 003/271] refactor: remove unreachable code (#13400) --- .../worker-terminal-ownership.ts | 30 ------------- .../runtime/relay/relay-control-protocol.ts | 1 - .../rpc/methods/ui-state-schema-parity.ts | 10 ----- src/main/ssh/relay-protocol.ts | 16 ------- .../ssh/ssh-relay-session-test-fixtures.ts | 7 ---- .../remote-browser-stream-errors.ts | 4 -- .../cmd-j/palette-filter-options.ts | 8 ---- .../agent-map-label-declutter.ts | 8 ---- .../components/sidebar/FilterToggleRow.tsx | 2 - .../__fixtures__/xterm-bypass-event.ts | 17 -------- .../src/components/ui/button-group.tsx | 42 +------------------ src/renderer/src/components/ui/card.tsx | 22 +--------- .../src/components/ui/context-menu.tsx | 39 +---------------- .../src/components/ui/dropdown-menu.tsx | 5 --- src/renderer/src/components/ui/select.tsx | 16 ------- src/renderer/src/components/ui/sheet.tsx | 18 +------- .../src/lib/agent-feature-install-commands.ts | 1 - .../src/lib/ime-composition-keyboard-event.ts | 10 ----- src/shared/agent-feature-install-commands.ts | 4 -- src/shared/daemon-lifecycle-telemetry.ts | 5 --- src/shared/hermes-run-ref-retention.ts | 1 - src/shared/left-sidebar-appearance.ts | 2 - src/shared/plugins/plugin-capabilities.ts | 4 -- src/shared/plugins/plugin-panel-bridge.ts | 20 +-------- src/shared/terminal-title-agent-type.ts | 4 -- 25 files changed, 5 insertions(+), 291 deletions(-) delete mode 100644 src/renderer/src/components/terminal-pane/__fixtures__/xterm-bypass-event.ts diff --git a/src/main/runtime/orchestration/worker-terminal-ownership.ts b/src/main/runtime/orchestration/worker-terminal-ownership.ts index d6f160cda4a..022146f0e75 100644 --- a/src/main/runtime/orchestration/worker-terminal-ownership.ts +++ b/src/main/runtime/orchestration/worker-terminal-ownership.ts @@ -75,36 +75,6 @@ export const WORKER_SETTLED_STATES: readonly WorkerDispatchState[] = [ export const WORKER_RELEASABLE_STATES: readonly WorkerDispatchState[] = ['succeeded', 'failed'] -// Same-operation creation evidence: worker-start labels its own agent terminal 'created' -// (existing worktree) or 'reused_agent_terminal' (agent-first worktree creation). An explicit -// --terminal reuse is labeled 'reused' and never claims ownership. -export function residualResourcesClaimAgentTerminal( - residualResourcesJson: string, - terminalHandle: string -): boolean { - let parsed: unknown - try { - parsed = JSON.parse(residualResourcesJson) - } catch { - return false - } - if (!Array.isArray(parsed)) { - return false - } - return parsed.some((effect) => { - if (!effect || typeof effect !== 'object') { - return false - } - const candidate = effect as { kind?: unknown; role?: unknown; action?: unknown; id?: unknown } - return ( - candidate.kind === 'terminal' && - candidate.role === 'agent' && - candidate.id === terminalHandle && - (candidate.action === 'created' || candidate.action === 'reused_agent_terminal') - ) - }) -} - // Process accounting for worker-list; deliberately independent of Task/Dispatch outcome. export function deriveWorkerTerminalListState(params: { workerState: WorkerDispatchState diff --git a/src/main/runtime/relay/relay-control-protocol.ts b/src/main/runtime/relay/relay-control-protocol.ts index 06cf61a95fd..c94797f4815 100644 --- a/src/main/runtime/relay/relay-control-protocol.ts +++ b/src/main/runtime/relay/relay-control-protocol.ts @@ -119,7 +119,6 @@ export const RelayControlErrorMessageSchema = z }) .strict() -export type RelayHostChallengeMessage = z.infer export type RelayHostHelloAckMessage = z.infer export type RelayConnectionOpenMessage = z.infer export type RelayDrainMessage = z.infer diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity.ts index 29d205e6092..555e8f44cfb 100644 --- a/src/main/runtime/rpc/methods/ui-state-schema-parity.ts +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity.ts @@ -13,16 +13,6 @@ export type AssertNoMissingKeys> ? true : { missingFromSchema: Exclude } -/** - * The mirror of `AssertNoMissingKeys`: a key the schema accepts but the shared - * type does not model. `.strict()` cannot catch this one — it lets the payload - * through, and the renderer then receives a field it has no type for. - */ -export type AssertNoExtraKeys> = - Exclude extends never - ? true - : { absentFromType: Exclude } - /** * Key parity alone is blind to VALUE drift: a schema can list `rightSidebarTab` * yet omit half its union members, which the strict schema then rejects. This diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index da61921ca6a..8c9f35b96d1 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -63,24 +63,8 @@ export const JsonRpcErrorCode = { * 256KB raw → ~340KB base64, well under MAX_MESSAGE_SIZE. */ export const STREAM_CHUNK_SIZE = 256 * 1024 -/** Cap on concurrent in-flight streams per relay; mirrors fs.watch's - * 20-watcher cap idiom. Prevents file-descriptor exhaustion from a buggy - * client. */ -export const MAX_CONCURRENT_STREAMS = 16 - // ── Git response streaming (see docs/relay-git-response-stream-design.md) ── -/** Serialized-JSON size above which the relay chunks a streamable git response - * (diff family + exec) onto the bulk lane instead of one JSON-RPC frame. Mirror - * of the relay-side constant; the client only opts in — the relay owns the - * decision — so this is documentation of the shared contract. */ -export const GIT_RESPONSE_STREAM_THRESHOLD = 256 * 1024 - -/** Per-chunk size (serialized-result UTF-8 bytes) for git response streaming. - * The client reassembles by concatenation and does not depend on this value, - * so it stays cross-version safe. */ -export const GIT_RESPONSE_CHUNK_SIZE = 128 * 1024 - /** Sentinel the relay returns as the RPC result when the real payload streams * as git.responseChunk frames. Absent from old relays, so a new client falls * back to the plain result they return. */ diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index 48461af68ca..c5f084a8785 100644 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -1,6 +1,5 @@ import { vi, type Mock } from 'vitest' import type { BrowserWindow } from 'electron' -import { PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR } from '../../shared/pty-consumer-session' import type { SshConnection } from './ssh-connection' import type { Store } from '../persistence' import type { SshPortForwardManager } from './ssh-port-forward' @@ -54,9 +53,3 @@ export function mockDeploySuccess(): void { platform: 'linux-x64' }) } - -export function createMismatchedOwnerRecoveryError(): unknown { - return Object.assign(new Error('Owner recovery lease is stale'), { - code: PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR - }) -} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-errors.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-errors.ts index 7db8458d266..6e37bb3021a 100644 --- a/src/renderer/src/components/browser-pane/remote-browser-stream-errors.ts +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-errors.ts @@ -48,10 +48,6 @@ function readErrorCode(error: unknown): string | null { return typeof code === 'string' ? code : null } -export function isRemoteBrowserStreamUnsupportedError(error: unknown): boolean { - return readErrorCode(error) === REMOTE_BROWSER_STREAM_UNSUPPORTED -} - export function isRemoteBrowserPageMissingCode(code: unknown): boolean { return typeof code === 'string' && REMOTE_BROWSER_PAGE_MISSING_CODES.has(code) } diff --git a/src/renderer/src/components/cmd-j/palette-filter-options.ts b/src/renderer/src/components/cmd-j/palette-filter-options.ts index 58dfabb8eb2..9eb5fdce4ac 100644 --- a/src/renderer/src/components/cmd-j/palette-filter-options.ts +++ b/src/renderer/src/components/cmd-j/palette-filter-options.ts @@ -51,14 +51,6 @@ export type PaletteFilterModel = { defaultHostId: ExecutionHostId } -export const EMPTY_PALETTE_FILTER_MODEL: PaletteFilterModel = { - hosts: [], - projects: [], - repoIdsByProjectKey: new Map(), - hostIdByRepoId: new Map(), - defaultHostId: LOCAL_EXECUTION_HOST_ID -} - /** * Precomputes only the repos that actually carry a host stamp so the lookup miss * below stays equivalent to getWorktreeExecutionHostId's `defaultHostId` branch. diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts index 020d8453dd2..58a1f53c4d6 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts @@ -49,14 +49,6 @@ function textWidth(text: string, fontPx: number, ratio = GLYPH_WIDTH_RATIO): num return text.length * fontPx * ratio } -export function agentMapWorktreeLabelWidth(text: string): number { - return textWidth(text, WORKTREE_LABEL_FONT_PX) -} - -export function agentMapCountLabelWidth(text: string): number { - return textWidth(text, COUNT_FONT_PX) -} - function boxesOverlap(a: LabelBox, b: LabelBox): boolean { return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom } diff --git a/src/renderer/src/components/sidebar/FilterToggleRow.tsx b/src/renderer/src/components/sidebar/FilterToggleRow.tsx index 7fbc0b77874..bcb47550649 100644 --- a/src/renderer/src/components/sidebar/FilterToggleRow.tsx +++ b/src/renderer/src/components/sidebar/FilterToggleRow.tsx @@ -69,5 +69,3 @@ export function FilterToggleRow({ ) } - -export default FilterToggleRow diff --git a/src/renderer/src/components/terminal-pane/__fixtures__/xterm-bypass-event.ts b/src/renderer/src/components/terminal-pane/__fixtures__/xterm-bypass-event.ts deleted file mode 100644 index f4b78599107..00000000000 --- a/src/renderer/src/components/terminal-pane/__fixtures__/xterm-bypass-event.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { XtermBypassEvent } from '../xterm-bypass-policy' - -// Shared test fixture: builds a fully-defaulted XtermBypassEvent so the bypass -// and IME candidate-guard suites all stay in sync when the event shape changes. -export function event(overrides: Partial): XtermBypassEvent { - return { - type: 'keydown', - key: '', - code: '', - defaultPrevented: false, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - ...overrides - } -} diff --git a/src/renderer/src/components/ui/button-group.tsx b/src/renderer/src/components/ui/button-group.tsx index 4cd3151d485..6607102236e 100644 --- a/src/renderer/src/components/ui/button-group.tsx +++ b/src/renderer/src/components/ui/button-group.tsx @@ -1,8 +1,6 @@ import * as React from 'react' import { cva, type VariantProps } from 'class-variance-authority' -import { Slot } from 'radix-ui' -import { Separator } from '@/components/ui/separator' import { cn } from '@/lib/utils' const buttonGroupVariants = cva( @@ -38,42 +36,4 @@ function ButtonGroup({ ) } -function ButtonGroupText({ - className, - asChild = false, - ...props -}: React.ComponentProps<'div'> & { - asChild?: boolean -}) { - const Comp = asChild ? Slot.Root : 'div' - - return ( - - ) -} - -function ButtonGroupSeparator({ - className, - orientation = 'vertical', - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants } +export { ButtonGroup, buttonGroupVariants } diff --git a/src/renderer/src/components/ui/card.tsx b/src/renderer/src/components/ui/card.tsx index 15f22c2bf1d..38fadc90124 100644 --- a/src/renderer/src/components/ui/card.tsx +++ b/src/renderer/src/components/ui/card.tsx @@ -48,28 +48,8 @@ function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { ) } -function CardAction({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ) -} - function CardContent({ className, ...props }: React.ComponentProps<'div'>) { return
} -function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ) -} - -export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent } +export { Card, CardHeader, CardTitle, CardDescription, CardContent } diff --git a/src/renderer/src/components/ui/context-menu.tsx b/src/renderer/src/components/ui/context-menu.tsx index 2b0423e0cd7..60ab6e38055 100644 --- a/src/renderer/src/components/ui/context-menu.tsx +++ b/src/renderer/src/components/ui/context-menu.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react' +import { ChevronRightIcon, CircleIcon } from 'lucide-react' import { ContextMenu as ContextMenuPrimitive } from 'radix-ui' import { cn } from '@/lib/utils' @@ -14,14 +14,6 @@ function ContextMenuTrigger({ return } -function ContextMenuGroup({ ...props }: React.ComponentProps) { - return -} - -function ContextMenuPortal({ ...props }: React.ComponentProps) { - return -} - function ContextMenuSub({ ...props }: React.ComponentProps) { return } @@ -123,32 +115,6 @@ function ContextMenuItem({ ) } -function ContextMenuCheckboxItem({ - className, - children, - checked, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ) -} - function ContextMenuRadioItem({ className, children, @@ -224,13 +190,10 @@ export { ContextMenuTrigger, ContextMenuContent, ContextMenuItem, - ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, - ContextMenuGroup, - ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx index d7fa2787671..eeebe9a4d2d 100644 --- a/src/renderer/src/components/ui/dropdown-menu.tsx +++ b/src/renderer/src/components/ui/dropdown-menu.tsx @@ -45,10 +45,6 @@ function DropdownMenuContent({ ) } -function DropdownMenuGroup({ ...props }: React.ComponentProps) { - return -} - function DropdownMenuItem({ className, inset, @@ -229,7 +225,6 @@ export { DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, diff --git a/src/renderer/src/components/ui/select.tsx b/src/renderer/src/components/ui/select.tsx index d25c887b328..c94cc4659fc 100644 --- a/src/renderer/src/components/ui/select.tsx +++ b/src/renderer/src/components/ui/select.tsx @@ -10,10 +10,6 @@ function Select({ ...props }: React.ComponentProps) return } -function SelectGroup({ ...props }: React.ComponentProps) { - return -} - function SelectValue({ ...props }: React.ComponentProps) { return } @@ -88,16 +84,6 @@ function SelectContent({ ) } -function SelectLabel({ className, ...props }: React.ComponentProps) { - return ( - - ) -} - function SelectItem({ className, children, @@ -171,9 +157,7 @@ function SelectScrollDownButton({ export { Select, SelectContent, - SelectGroup, SelectItem, - SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, diff --git a/src/renderer/src/components/ui/sheet.tsx b/src/renderer/src/components/ui/sheet.tsx index 0e722f1a4f5..24f5dfcebe5 100644 --- a/src/renderer/src/components/ui/sheet.tsx +++ b/src/renderer/src/components/ui/sheet.tsx @@ -12,10 +12,6 @@ function Sheet({ ...props }: React.ComponentProps) { return } -function SheetTrigger({ ...props }: React.ComponentProps) { - return -} - function SheetClose({ ...props }: React.ComponentProps) { return } @@ -124,16 +120,6 @@ function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { ) } -function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ) -} - function SheetTitle({ className, ...props }: React.ComponentProps) { return ( -export function isPluginCapabilityKind(value: string): value is PluginCapabilityKind { - return (PLUGIN_CAPABILITY_KINDS as readonly string[]).includes(value) -} - /** Plain-language consent copy per capability. Shown verbatim in the install * preview / consent dialog; keep each line honest about what is enforced. */ export const PLUGIN_CAPABILITY_DESCRIPTIONS: Record = { diff --git a/src/shared/plugins/plugin-panel-bridge.ts b/src/shared/plugins/plugin-panel-bridge.ts index 95c8a62d5ff..7d659fcc97f 100644 --- a/src/shared/plugins/plugin-panel-bridge.ts +++ b/src/shared/plugins/plugin-panel-bridge.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { getPluginHostMethodSpec, isPluginPanelAction } from './plugin-host-api' +import { isPluginPanelAction } from './plugin-host-api' /** * postMessage protocol between a sandboxed plugin panel iframe and the host @@ -150,21 +150,3 @@ export function readPanelPongId(data: unknown): number | null { // wider reader would admit ids the watchdog can never have issued. return Number.isSafeInteger(frame.pingId) && frame.pingId >= 0 ? frame.pingId : null } - -/** Validates action params against the host API spec (shared with workers). */ -export function parsePanelActionParams( - action: string, - params: unknown -): { ok: true; params: unknown } | { ok: false; error: string } { - const spec = getPluginHostMethodSpec(action) - if (!spec) { - return { ok: false, error: `unknown action: ${action}` } - } - const parsed = spec.params.safeParse(params) - if (!parsed.success) { - const issue = parsed.error.issues[0] - const path = issue?.path.join('.') || '(root)' - return { ok: false, error: `${path}: ${issue?.message ?? 'invalid action params'}` } - } - return { ok: true, params: parsed.data } -} diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index 6287648739b..7e38a227f34 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -51,10 +51,6 @@ export function isGeminiTerminalTitle(title: string): boolean { return titleHasAgentName(title, 'gemini') } -export function isPiTerminalTitle(title: string): boolean { - return isLegacyPiCompatibleTitle(title) && !containsBrailleSpinner(title) -} - // Why: Grok Build's working OSC titles use a fixed frame shape — // "spinner - - grok" — so every frame is a distinct title // that flips tab and sidebar labels. Require BOTH the post-spinner " - " From 954de385077e379e969c89d0a2497d514b3c0590 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:30:44 -0700 Subject: [PATCH 004/271] refactor(mobile): remove unused PR and review code (#13404) --- .../src/components/MobilePrComposeSheet.tsx | 52 ------------------- mobile/src/components/mobile-pr-url.ts | 6 +++ .../pr-sidebar/MobilePrComposeForm.tsx | 6 +-- .../components/pr-sidebar/PRSidebarHeader.tsx | 2 +- .../pr-sidebar/PrSidebarCreateEmptyState.tsx | 2 +- .../src/session/mobile-diff-review-state.ts | 25 --------- .../use-native-chat-action-outcomes.ts | 13 ----- .../MobileSourceControlModals.tsx | 2 +- .../MobileSourceControlPanel.tsx | 2 +- .../source-control/mobile-base-ref-search.ts | 21 -------- .../source-control/mobile-open-pr-prefill.ts | 26 +--------- 11 files changed, 12 insertions(+), 145 deletions(-) delete mode 100644 mobile/src/components/MobilePrComposeSheet.tsx create mode 100644 mobile/src/components/mobile-pr-url.ts diff --git a/mobile/src/components/MobilePrComposeSheet.tsx b/mobile/src/components/MobilePrComposeSheet.tsx deleted file mode 100644 index 124f9192256..00000000000 --- a/mobile/src/components/MobilePrComposeSheet.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Linking } from 'react-native' -import { BottomDrawer } from './BottomDrawer' -import { MobilePrComposeForm, type PrComposePrefill } from './pr-sidebar/MobilePrComposeForm' -import type { RpcClient } from '../transport/rpc-client' - -type Props = { - visible: boolean - client: RpcClient | null - worktreeId: string - prefill: PrComposePrefill - // Head branch — enables the base≠head guard and the "from " hint. - head?: string | null - onClose: () => void - onCreated: (url: string, warning?: string) => void -} - -// BottomDrawer wrapper around the inline compose form, for full-screen roots -// (source-control modals). The PR sidebar empty-state renders MobilePrComposeForm -// inline instead, since a BottomDrawer overlay nested in a ScrollView clips it. -export function MobilePrComposeSheet({ - visible, - client, - worktreeId, - prefill, - head, - onClose, - onCreated -}: Props) { - return ( - - {/* Why: key on visible + prefill fields so reopening (or a new prefill) - remounts the form with fresh initial values; the previous sheet reset - its fields via an effect on the same signals. */} - - - ) -} - -export function openMobilePrUrl(url: string): void { - // Why: Linking.openURL rejects when iOS/Android can't open the URL (no app, - // bad scheme, etc.). Without a catch that surfaces as LogBox "Uncaught - // (in promise) Error: Unable to open URL…" over the PR screen. - void Linking.openURL(url).catch(() => {}) -} diff --git a/mobile/src/components/mobile-pr-url.ts b/mobile/src/components/mobile-pr-url.ts new file mode 100644 index 00000000000..3f642d5ccf6 --- /dev/null +++ b/mobile/src/components/mobile-pr-url.ts @@ -0,0 +1,6 @@ +import { Linking } from 'react-native' + +export function openMobilePrUrl(url: string): void { + // Prevent unhandled rejections when no app accepts the URL. + void Linking.openURL(url).catch(() => {}) +} diff --git a/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx b/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx index 618e399d1a0..d832d38c88d 100644 --- a/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx +++ b/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx @@ -38,11 +38,7 @@ type Props = { onCreated: (url: string, warning?: string) => void } -// PR compose form body: title/body/base/draft with AI prefill (git.generate -// PullRequestFields), submitting via createMobilePr. Renders a plain View so it -// can sit inline inside the PR sidebar's existing ScrollView (a BottomDrawer -// overlay trapped in a ScrollView clips the form). The BottomDrawer wrapper -// MobilePrComposeSheet reuses this body at full-screen roots. +// A plain View keeps the compose form usable inside the PR sidebar ScrollView. export function MobilePrComposeForm({ client, worktreeId, diff --git a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx index 8a399babe78..335108e07ae 100644 --- a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx +++ b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx @@ -7,7 +7,7 @@ import type { MobilePrTitleAction } from '../../session/use-mobile-pr-title-acti import { prStateBadge } from './pr-checks-presentation' import { statusColor } from './pr-sidebar-status-color' import { canEditPRTitle } from '../../session/pr-title-edit' -import { openMobilePrUrl } from '../MobilePrComposeSheet' +import { openMobilePrUrl } from '../mobile-pr-url' import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles' import { prCommentComposerStyles as composerStyles } from './pr-comment-composer-styles' diff --git a/mobile/src/components/pr-sidebar/PrSidebarCreateEmptyState.tsx b/mobile/src/components/pr-sidebar/PrSidebarCreateEmptyState.tsx index ebc724d947d..cffde86ea5b 100644 --- a/mobile/src/components/pr-sidebar/PrSidebarCreateEmptyState.tsx +++ b/mobile/src/components/pr-sidebar/PrSidebarCreateEmptyState.tsx @@ -18,7 +18,7 @@ import { runMobileHostedReviewCreateIntent } from '../../source-control/mobile-hosted-review-create-intent-runner' import { fetchWorktreeLinkedPR } from '../../source-control/mobile-pr-link' -import { openMobilePrUrl } from '../MobilePrComposeSheet' +import { openMobilePrUrl } from '../mobile-pr-url' import { MobileLinkPrForm } from './MobileLinkPrForm' import { prCreateEmptyStateStyles as styles } from './pr-create-empty-state-styles' diff --git a/mobile/src/session/mobile-diff-review-state.ts b/mobile/src/session/mobile-diff-review-state.ts index a9bab23471b..a9680a66df5 100644 --- a/mobile/src/session/mobile-diff-review-state.ts +++ b/mobile/src/session/mobile-diff-review-state.ts @@ -104,31 +104,6 @@ export function mergeMobileDiffReviewState( } } -export function markMobileDiffReviewFileOpened( - state: MobileDiffReviewState, - descriptor: MobileDiffReviewFileDescriptor, - now: number -): MobileDiffReviewState { - const previous = state.files[descriptor.key] - return { - ...state, - updatedAt: now, - files: { - ...state.files, - [descriptor.key]: { - key: descriptor.key, - filePath: descriptor.filePath, - oldPath: descriptor.oldPath, - scope: descriptor.scope, - reviewedAt: previous?.reviewedAt, - reviewDiffIdentity: previous?.reviewDiffIdentity, - lastOpenedAt: now, - lastSeenDiffIdentity: descriptor.diffIdentity - } - } - } -} - export function markMobileDiffReviewFileReviewed( state: MobileDiffReviewState, descriptor: MobileDiffReviewFileDescriptor, diff --git a/mobile/src/session/use-native-chat-action-outcomes.ts b/mobile/src/session/use-native-chat-action-outcomes.ts index 51a6f831b5a..a81d08dfdff 100644 --- a/mobile/src/session/use-native-chat-action-outcomes.ts +++ b/mobile/src/session/use-native-chat-action-outcomes.ts @@ -1,5 +1,4 @@ import { useCallback } from 'react' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' /** Wraps a chat card action so an accepted write also retires the route's held * failure banner. @@ -23,15 +22,3 @@ export function useNativeChatAcceptedAction( [action, onAccepted] ) } - -/** Boolean surface for callers with no pre-pasted input: 'unknown' stays true - * (the send usually landed; the optimistic echo is already held unconfirmed). */ -export function useNativeChatSentFlag( - send: (text: string, images?: string[]) => Promise -): (text: string, images?: string[]) => Promise { - return useCallback( - async (text: string, images?: string[]): Promise => - (await send(text, images)) !== 'rejected', - [send] - ) -} diff --git a/mobile/src/source-control/MobileSourceControlModals.tsx b/mobile/src/source-control/MobileSourceControlModals.tsx index b58d2520723..f9659240e4a 100644 --- a/mobile/src/source-control/MobileSourceControlModals.tsx +++ b/mobile/src/source-control/MobileSourceControlModals.tsx @@ -1,7 +1,7 @@ import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal' import { ConfirmModal } from '../components/ConfirmModal' import { PickerModal } from '../components/PickerModal' -import { openMobilePrUrl } from '../components/MobilePrComposeSheet' +import { openMobilePrUrl } from '../components/mobile-pr-url' import { MobileBranchDiffPreviewDrawer } from './MobileBranchDiffPreviewDrawer' import type { MobileSourceControlState } from './use-mobile-source-control-state' diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx index 9c3ebb27062..3244d440b01 100644 --- a/mobile/src/source-control/MobileSourceControlPanel.tsx +++ b/mobile/src/source-control/MobileSourceControlPanel.tsx @@ -18,7 +18,7 @@ import { isMobileConflictAborting } from './mobile-source-control-conflict-abort import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller' import { prSidebarDetailsNeedFetch } from '../session/mobile-pr-sidebar-state' import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel' -import { openMobilePrUrl } from '../components/MobilePrComposeSheet' +import { openMobilePrUrl } from '../components/mobile-pr-url' export type MobileSourceControlPanelProps = { hostId: string diff --git a/mobile/src/source-control/mobile-base-ref-search.ts b/mobile/src/source-control/mobile-base-ref-search.ts index 8e51fe0d582..84a962a41e5 100644 --- a/mobile/src/source-control/mobile-base-ref-search.ts +++ b/mobile/src/source-control/mobile-base-ref-search.ts @@ -2,13 +2,6 @@ import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' import { mobileRepoSelectorFromWorktreeId } from './mobile-pr-create' -// Base-branch selection for the create-PR composer, mirroring the desktop -// useCreatePullRequestDialogFields flow: a default ref from repo.baseRefDefault and -// a debounced search via repo.searchRefs (both allowlisted for mobile). Result -// mapping is pure + unit-tested; the wrappers are thin sendRequest calls. - -// Defensively normalize the repo.searchRefs payload (`{ refs: string[] }`) to a -// clean string[] — drops non-string / malformed entries instead of throwing. export function mapBaseRefResults(raw: unknown): string[] { if (raw === null || typeof raw !== 'object') { return [] @@ -20,20 +13,6 @@ export function mapBaseRefResults(raw: unknown): string[] { return refs.filter((r): r is string => typeof r === 'string' && r.length > 0) } -export async function fetchDefaultBaseRef( - client: Pick, - worktreeId: string -): Promise { - const response = await client.sendRequest('repo.baseRefDefault', { - repo: mobileRepoSelectorFromWorktreeId(worktreeId) - }) - if (!response.ok) { - return null - } - const result = (response as RpcSuccess).result as { defaultBaseRef?: string | null } - return typeof result?.defaultBaseRef === 'string' ? result.defaultBaseRef : null -} - export async function searchBaseRefs( client: Pick, worktreeId: string, diff --git a/mobile/src/source-control/mobile-open-pr-prefill.ts b/mobile/src/source-control/mobile-open-pr-prefill.ts index 705b4dc68e5..2369095f328 100644 --- a/mobile/src/source-control/mobile-open-pr-prefill.ts +++ b/mobile/src/source-control/mobile-open-pr-prefill.ts @@ -1,14 +1,7 @@ -import type { RpcClient } from '../transport/rpc-client' import { readMobileGitStatusResult } from '../session/mobile-diff-review-rpc' import type { MobileGitStatusResult } from './mobile-git-status' -import { resolveMobilePrPrefill, type MobilePrPrefill } from './mobile-pr-create' -// Resolves the create-PR prefill from a git status snapshot. Split from the -// runners hook to keep that file under the line limit. - -// Reads a fresh git.status after a push so the prefill reflects the just-pushed -// branch's upstream/ahead data instead of the pre-push captured status. Best-effort: -// returns the captured status on any read failure. +// Refresh after a push when possible so readiness reflects the new upstream state. export async function readFreshGitStatus( worktreeId: string, fallback: MobileGitStatusResult | null, @@ -22,23 +15,6 @@ export async function readFreshGitStatus( } } -export async function buildOpenPrPrefill( - client: Pick | null, - worktreeId: string, - status: MobileGitStatusResult | null, - branchLabel: string -): Promise { - if (!client) { - return { provider: 'github', base: 'main', title: branchLabel, body: '' } - } - const gitReadiness = getMobilePrEligibilityReadiness(status) - return resolveMobilePrPrefill(client, worktreeId, { - branch: status?.branch, - title: branchLabel, - ...gitReadiness - }) -} - export function getMobilePrEligibilityReadiness(status: MobileGitStatusResult | null): { hasUncommittedChanges?: boolean hasUpstream?: boolean From c56ff58dd736bb163fa682ca995ef9ae996a2f5a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:31:00 -0700 Subject: [PATCH 005/271] test(mobile): centralize renderer setup (#13408) --- .../src/accounts-route-reset-credit.test.ts | 25 +- .../components/CodexResetCreditAction.test.ts | 44 +-- .../src/components/HostProtocolGate.test.ts | 1 - .../components/MobileHomeQuickActions.test.ts | 6 +- mobile/src/components/MobileHostCard.test.ts | 6 +- mobile/src/components/MobileHostCard.test.tsx | 6 +- .../MobileMarkdown.file-links.test.ts | 1 - .../src/components/NewWorktreeModal.test.tsx | 1 - .../PickerModal.accessibility.test.ts | 1 - .../src/components/WorktreeAgentList.test.tsx | 6 +- mobile/src/components/WorktreeListRow.test.ts | 1 - .../bottom-drawer-close-lifecycle.test.ts | 4 +- .../mobile-agent-icon-gradient.test.ts | 6 +- .../pr-sidebar/PRCommentsSection.test.ts | 24 +- .../src/files/MobileFileExplorerPanel.test.ts | 37 +- .../files/MobileFileMarkdownPreview.test.ts | 23 +- mobile/src/hooks/use-now.test.ts | 12 +- .../src/host-edit-route-accessibility.test.ts | 26 +- mobile/src/host-edit-save-flow.test.ts | 27 +- .../onboarding/MobileOnboardingPage.test.ts | 6 +- .../mobile-onboarding-screen.test.ts | 1 - .../session/MobileNativeChatComposer.test.ts | 373 ++++++++---------- .../session/MobileNativeChatMessage.test.ts | 20 +- .../session/MobileNativeChatOverlay.test.ts | 28 +- .../MobileNativeChatPermission.test.ts | 31 +- ...bileNativeChatSessionOptionPickers.test.ts | 1 - .../src/session/MobileNativeChatView.test.ts | 28 +- mobile/src/session/QuickCommandsList.test.ts | 4 +- mobile/src/session/QuickCommandsSheet.test.ts | 13 +- ...mobile-native-chat-permission-send.test.ts | 1 - ...nitial-session-terminal-autocreate.test.ts | 1 - .../session/use-live-worktree-name.test.ts | 54 +-- .../use-missing-worktree-bounce.test.ts | 1 - ...mobile-attachment-input-lease-gate.test.ts | 10 - ...le-default-session-view-preference.test.ts | 1 - .../use-mobile-diff-review-controller.test.ts | 1 - ...se-mobile-diff-review-diff-loading.test.ts | 1 - ...se-mobile-diff-review-send-actions.test.ts | 16 +- .../use-mobile-file-tap-handlers.test.ts | 1 - ...use-mobile-native-chat-answer-send.test.ts | 16 +- ...use-mobile-native-chat-ask-dismiss.test.ts | 18 +- .../use-mobile-native-chat-controller.test.ts | 64 +-- ...le-native-chat-drafts-launch-draft.test.ts | 21 +- .../use-mobile-native-chat-drafts.test.ts | 21 +- ...use-mobile-native-chat-file-search.test.ts | 23 +- ...bile-native-chat-image-attachments.test.ts | 16 +- ...use-mobile-native-chat-input-lease.test.ts | 86 ++-- ...se-mobile-native-chat-message-send.test.ts | 1 - ...use-mobile-native-chat-readability.test.ts | 55 +-- .../use-mobile-native-chat-send-error.test.ts | 68 +--- ...mobile-native-chat-session-options.test.ts | 1 - .../use-mobile-native-chat-session.test.ts | 32 +- .../use-mobile-native-chat-stop.test.ts | 1 - ...mobile-native-chat-terminal-stream.test.ts | 297 ++++++-------- ...mobile-session-tabs-reconciliation.test.ts | 11 - .../use-mobile-session-view-mode.test.ts | 1 - mobile/src/session/use-quick-commands.test.ts | 16 +- ...erminal-live-input-mode-preference.test.ts | 23 +- .../use-throttled-latest-value.test.ts | 12 +- .../MobileGitHistoryList.test.tsx | 6 +- ...e-mobile-hosted-review-eligibility.test.ts | 6 +- .../terminal-webview-engine-error.test.ts | 23 +- ...rminal-live-accessory-input-commit.test.ts | 23 +- .../use-terminal-live-input-commit.test.ts | 23 +- .../use-terminal-live-input-focus.test.ts | 28 +- mobile/src/transport/client-context.test.ts | 64 +-- .../src/transport/host-status-gates.test.ts | 19 - .../settings-host-client-lifecycle.test.ts | 130 ++---- mobile/vitest.config.ts | 2 + mobile/vitest.setup.ts | 1 + 70 files changed, 508 insertions(+), 1449 deletions(-) create mode 100644 mobile/vitest.setup.ts diff --git a/mobile/src/accounts-route-reset-credit.test.ts b/mobile/src/accounts-route-reset-credit.test.ts index 76be8071930..2de1e96881d 100644 --- a/mobile/src/accounts-route-reset-credit.test.ts +++ b/mobile/src/accounts-route-reset-credit.test.ts @@ -157,28 +157,12 @@ const RESET_SNAPSHOT = { } } as const -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => spy.mockRestore() -} - async function renderAccountsRoute(): Promise { let renderer: ReactTestRenderer | null = null - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(AccountsScreen)) - await Promise.resolve() - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(AccountsScreen)) + await Promise.resolve() + }) if (!renderer) { throw new Error('Accounts route did not render') } @@ -229,7 +213,6 @@ describe('accounts route Codex reset credit', () => { let storedValues: Map beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true resetCodexResetAttemptJournalForTests() storedValues = new Map() dependencies.alert.mockReset() diff --git a/mobile/src/components/CodexResetCreditAction.test.ts b/mobile/src/components/CodexResetCreditAction.test.ts index 25e39591701..0c1a6ea57b4 100644 --- a/mobile/src/components/CodexResetCreditAction.test.ts +++ b/mobile/src/components/CodexResetCreditAction.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { CodexResetCreditAction } from './CodexResetCreditAction' vi.mock('react-native', () => ({ @@ -19,35 +19,19 @@ const summary = { expiryLabel: 'Expires in 5d' } -function suppressRendererWarning(): () => void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - return () => spy.mockRestore() -} - function renderAction(busy: boolean, disabled: boolean): ReactTestRenderer { let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - act(() => { - renderer = create( - createElement(CodexResetCreditAction, { - summary, - scopeLabel: 'dev@example.com on the host', - busy, - disabled, - onPress: vi.fn() - }) - ) - }) - } finally { - restore() - } + act(() => { + renderer = create( + createElement(CodexResetCreditAction, { + summary, + scopeLabel: 'dev@example.com on the host', + busy, + disabled, + onPress: vi.fn() + }) + ) + }) if (!renderer) { throw new Error('Reset action did not render') } @@ -55,10 +39,6 @@ function renderAction(busy: boolean, disabled: boolean): ReactTestRenderer { } describe('CodexResetCreditAction', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - it('exposes a 44pt touch target and enabled accessibility state', () => { const renderer = renderAction(false, false) const button = renderer.root.findByType('Pressable') diff --git a/mobile/src/components/HostProtocolGate.test.ts b/mobile/src/components/HostProtocolGate.test.ts index 94cae6d9170..44a2265ccb3 100644 --- a/mobile/src/components/HostProtocolGate.test.ts +++ b/mobile/src/components/HostProtocolGate.test.ts @@ -78,7 +78,6 @@ describe('HostProtocolGate', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true nativeTestState.openUrl.mockClear() nativeTestState.platform.OS = 'ios' probeMounts.count = 0 diff --git a/mobile/src/components/MobileHomeQuickActions.test.ts b/mobile/src/components/MobileHomeQuickActions.test.ts index ba2e0ba750f..e71e1d8ff83 100644 --- a/mobile/src/components/MobileHomeQuickActions.test.ts +++ b/mobile/src/components/MobileHomeQuickActions.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { HostProfile } from '../transport/types' import { MobileHomeQuickActions } from './MobileHomeQuickActions' @@ -37,10 +37,6 @@ function host(id: string, name: string, endpoint: string): HostProfile { describe('MobileHomeQuickActions', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/components/MobileHostCard.test.ts b/mobile/src/components/MobileHostCard.test.ts index f7c494cfdbf..f4033e67112 100644 --- a/mobile/src/components/MobileHostCard.test.ts +++ b/mobile/src/components/MobileHostCard.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { MobileHostCard } from './MobileHostCard' vi.mock('react-native', () => ({ @@ -30,10 +30,6 @@ function suppressRendererDeprecation() { describe('MobileHostCard', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/components/MobileHostCard.test.tsx b/mobile/src/components/MobileHostCard.test.tsx index 66e978a14ce..a921571555c 100644 --- a/mobile/src/components/MobileHostCard.test.tsx +++ b/mobile/src/components/MobileHostCard.test.tsx @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { ConnectionVerdict } from '../transport/connection-health' import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' import type { ConnectionState, HostCredentialStatus, HostProfile } from '../transport/types' @@ -39,10 +39,6 @@ const loaded: HostWorktreeInfo = { describe('MobileHostCard', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/components/MobileMarkdown.file-links.test.ts b/mobile/src/components/MobileMarkdown.file-links.test.ts index a8e381865d8..962538a8842 100644 --- a/mobile/src/components/MobileMarkdown.file-links.test.ts +++ b/mobile/src/components/MobileMarkdown.file-links.test.ts @@ -38,7 +38,6 @@ describe('MobileMarkdown file links', () => { const onOpenFile = vi.fn() beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true onOpenFile.mockClear() openURL.mockClear() }) diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx index 3fb76dd3122..4d8e95bec89 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -58,7 +58,6 @@ describe('NewWorktreeModal repo list', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true setCachedRepos('host-1', repos) }) diff --git a/mobile/src/components/PickerModal.accessibility.test.ts b/mobile/src/components/PickerModal.accessibility.test.ts index 4508f227554..46610a6e631 100644 --- a/mobile/src/components/PickerModal.accessibility.test.ts +++ b/mobile/src/components/PickerModal.accessibility.test.ts @@ -24,7 +24,6 @@ describe('PickerModal accessibility', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true vi.spyOn(console, 'error').mockImplementation((...args) => { if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) { throw new Error(String(args[0])) diff --git a/mobile/src/components/WorktreeAgentList.test.tsx b/mobile/src/components/WorktreeAgentList.test.tsx index 036f5d35996..aef5161cb4c 100644 --- a/mobile/src/components/WorktreeAgentList.test.tsx +++ b/mobile/src/components/WorktreeAgentList.test.tsx @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' import { WorktreeAgentList } from './WorktreeAgentList' @@ -39,10 +39,6 @@ function agent(paneKey: string, parentPaneKey: string | null = null): RuntimeWor describe('WorktreeAgentList', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/components/WorktreeListRow.test.ts b/mobile/src/components/WorktreeListRow.test.ts index 9fe0aa11b02..d0825412636 100644 --- a/mobile/src/components/WorktreeListRow.test.ts +++ b/mobile/src/components/WorktreeListRow.test.ts @@ -127,7 +127,6 @@ describe('memoized worktree rows', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true agentSpinnerRender.mockClear() agentStateDotRender.mockClear() }) diff --git a/mobile/src/components/bottom-drawer-close-lifecycle.test.ts b/mobile/src/components/bottom-drawer-close-lifecycle.test.ts index b4362412db7..94eb8a8495c 100644 --- a/mobile/src/components/bottom-drawer-close-lifecycle.test.ts +++ b/mobile/src/components/bottom-drawer-close-lifecycle.test.ts @@ -51,14 +51,12 @@ function mountedDrawer(renderer: ReactTestRenderer) { describe('BottomDrawer close lifecycle', () => { beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true const originalConsoleError = console.error vi.spyOn(console, 'error').mockImplementation((...args) => { const message = args[0] if ( typeof message === 'string' && - (message.includes('react-test-renderer is deprecated') || - message.includes('The current testing environment is not configured to support act')) + message.includes('The current testing environment is not configured to support act') ) { return } diff --git a/mobile/src/components/mobile-agent-icon-gradient.test.ts b/mobile/src/components/mobile-agent-icon-gradient.test.ts index 8e2781edfb2..c2221b62771 100644 --- a/mobile/src/components/mobile-agent-icon-gradient.test.ts +++ b/mobile/src/components/mobile-agent-icon-gradient.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { MobileAgentIcon } from './MobileAgentIcon' vi.mock('react-native', () => ({ @@ -35,10 +35,6 @@ vi.mock('./AgentIcons', () => ({ describe('MobileAgentIcon OMP gradient', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts b/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts index d174f455c67..08fc02c357b 100644 --- a/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts +++ b/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts @@ -32,18 +32,6 @@ vi.mock('./pr-comments-styles', () => ({ prCommentsStyles: {} })) vi.mock('./mobile-pr-sidebar-styles', () => ({ mobilePrSidebarStyles: {} })) vi.mock('../../theme/mobile-theme', () => ({ colors: { textSecondary: '#999' } })) -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - function comment(id: number): PRComment { return { id, @@ -65,14 +53,9 @@ function detailsWithComments(count: number): GitHubWorkItemDetails { async function renderComments(details: GitHubWorkItemDetails): Promise { let renderer: ReactTestRenderer | null = null - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(PRCommentsSection, { details, prState: 'open' })) - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(PRCommentsSection, { details, prState: 'open' })) + }) if (!renderer) { throw new Error('PRCommentsSection did not render') } @@ -107,7 +90,6 @@ describe('PRCommentsSection', () => { afterEach(() => { renderer?.unmount() renderer = null - vi.restoreAllMocks() }) it('resets pagination only when the user chooses a different audience filter', async () => { diff --git a/mobile/src/files/MobileFileExplorerPanel.test.ts b/mobile/src/files/MobileFileExplorerPanel.test.ts index 0d003e9f456..ff407ff21f6 100644 --- a/mobile/src/files/MobileFileExplorerPanel.test.ts +++ b/mobile/src/files/MobileFileExplorerPanel.test.ts @@ -83,18 +83,6 @@ vi.mock('../transport/client-context', () => ({ }) })) -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - function entry(name: string, isDirectory = false): MobileDirEntry { return { name, isDirectory } } @@ -113,21 +101,16 @@ function createMockClient(entriesByPath: Record): Mock async function renderExplorer(): Promise { let renderer: ReactTestRenderer | null = null - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileFileExplorerPanel, { - hostId: 'host-a', - worktreeId: 'worktree-a', - name: 'Example Worktree', - embedded: true - }) - ) - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create( + createElement(MobileFileExplorerPanel, { + hostId: 'host-a', + worktreeId: 'worktree-a', + name: 'Example Worktree', + embedded: true + }) + ) + }) if (!renderer) { throw new Error('MobileFileExplorerPanel did not render') } diff --git a/mobile/src/files/MobileFileMarkdownPreview.test.ts b/mobile/src/files/MobileFileMarkdownPreview.test.ts index 2afdb0b2ce9..8ac3770eb35 100644 --- a/mobile/src/files/MobileFileMarkdownPreview.test.ts +++ b/mobile/src/files/MobileFileMarkdownPreview.test.ts @@ -33,28 +33,11 @@ vi.mock('./mobile-file-preview-styles', () => ({ type PreviewProps = Parameters[0] -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - async function renderPreview(props: PreviewProps): Promise { let renderer: ReactTestRenderer | null = null - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(MobileFileMarkdownPreview, props)) - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(MobileFileMarkdownPreview, props)) + }) if (!renderer) { throw new Error('MobileFileMarkdownPreview did not render') } diff --git a/mobile/src/hooks/use-now.test.ts b/mobile/src/hooks/use-now.test.ts index c3fe56a980b..14264b513ee 100644 --- a/mobile/src/hooks/use-now.test.ts +++ b/mobile/src/hooks/use-now.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const appState = vi.hoisted(() => ({ current: 'active', @@ -25,7 +25,6 @@ import { useNow } from './use-now' describe('useNow', () => { let renderer: ReactTestRenderer | null = null let latest = 0 - let consoleSpy: MockInstance function Harness({ enabled = true }: { enabled?: boolean }): null { latest = useNow(1_000, enabled) @@ -42,18 +41,10 @@ describe('useNow', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(1_000) - globalThis.IS_REACT_ACT_ENVIRONMENT = true appState.current = 'active' appState.listener = null appState.remove.mockClear() latest = 0 - const original = console.error - consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) act(() => { renderer = create(createElement(Harness)) }) @@ -63,7 +54,6 @@ describe('useNow', () => { act(() => renderer?.unmount()) renderer = null vi.useRealTimers() - consoleSpy.mockRestore() }) it('ticks while active, pauses in the background, and refreshes immediately on resume', () => { diff --git a/mobile/src/host-edit-route-accessibility.test.ts b/mobile/src/host-edit-route-accessibility.test.ts index bb5ddf4faa6..d5fc20521a9 100644 --- a/mobile/src/host-edit-route-accessibility.test.ts +++ b/mobile/src/host-edit-route-accessibility.test.ts @@ -46,29 +46,12 @@ vi.mock('./transport/client-context', () => ({ usePrimeHosts: () => dependencies.primeHosts })) -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - async function renderEditHostRoute(): Promise { let renderer: ReactTestRenderer | null = null - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(EditHostScreen)) - await Promise.resolve() - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(EditHostScreen)) + await Promise.resolve() + }) if (!renderer) { throw new Error('Edit host route did not render') } @@ -77,7 +60,6 @@ async function renderEditHostRoute(): Promise { describe('edit host route accessibility', () => { beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true dependencies.loadHosts.mockReset().mockResolvedValue([ { id: 'host-1', diff --git a/mobile/src/host-edit-save-flow.test.ts b/mobile/src/host-edit-save-flow.test.ts index aa0069a7c69..a0a4561b2b2 100644 --- a/mobile/src/host-edit-save-flow.test.ts +++ b/mobile/src/host-edit-save-flow.test.ts @@ -56,29 +56,12 @@ const HOST_FIXTURE = { lastConnected: 1 } -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - async function renderEditHostRoute(): Promise { let renderer: ReactTestRenderer | null = null - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(EditHostScreen)) - await Promise.resolve() - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(EditHostScreen)) + await Promise.resolve() + }) if (!renderer) { throw new Error('Edit host route did not render') } @@ -135,7 +118,6 @@ function findText(renderer: ReactTestRenderer, match: string): boolean { describe('edit host handleSave', () => { beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true dependencies.hostId = 'host-1' dependencies.back.mockReset() dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined) @@ -285,7 +267,6 @@ describe('edit host handleSave', () => { describe('edit host load() error states', () => { beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true dependencies.hostId = 'host-1' dependencies.back.mockReset() dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined) diff --git a/mobile/src/onboarding/MobileOnboardingPage.test.ts b/mobile/src/onboarding/MobileOnboardingPage.test.ts index a99052c3ec9..d62e496f0bd 100644 --- a/mobile/src/onboarding/MobileOnboardingPage.test.ts +++ b/mobile/src/onboarding/MobileOnboardingPage.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { MobileOnboardingPage } from './MobileOnboardingPage' vi.mock('react-native', async () => { @@ -24,10 +24,6 @@ vi.mock('lucide-react-native', () => ({ describe('MobileOnboardingPage', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/onboarding/mobile-onboarding-screen.test.ts b/mobile/src/onboarding/mobile-onboarding-screen.test.ts index d571ce28a01..05dcc6b9ad8 100644 --- a/mobile/src/onboarding/mobile-onboarding-screen.test.ts +++ b/mobile/src/onboarding/mobile-onboarding-screen.test.ts @@ -56,7 +56,6 @@ describe('MobileOnboardingScreen', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true mocks.params = { hostId: 'paired-host', steps: 'session-view,notifications' } mocks.replace.mockReset() mocks.reducedMotionEnabled = false diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index 4ee001ca4af..eaf50ba08ab 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { radii, spacing } from '../theme/mobile-theme' import { MobileNativeChatComposer } from './MobileNativeChatComposer' @@ -43,24 +43,9 @@ vi.mock('../components/BottomDrawer', async () => { } }) -function suppressRendererWarning(): () => void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - return () => spy.mockRestore() -} - describe('MobileNativeChatComposer', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null @@ -71,21 +56,16 @@ describe('MobileNativeChatComposer', () => { onChangeText: () => void, isAttaching = false ) { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: ' hello ', - onChangeText, - onSend, - isAttaching - }) - ) - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: ' hello ', + onChangeText, + onSend, + isAttaching + }) + ) + }) } function sendButton(): { props: { onPress: () => Promise } } { @@ -130,20 +110,15 @@ describe('MobileNativeChatComposer', () => { it('preserves leading whitespace so prose is not turned into a slash command', async () => { const onSend = vi.fn().mockResolvedValue(true) - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: ' /clear is prose ', - onChangeText: vi.fn(), - onSend - }) - ) - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: ' /clear is prose ', + onChangeText: vi.fn(), + onSend + }) + ) + }) await act(async () => sendButton().props.onPress()) expect(onSend).toHaveBeenCalledWith(' /clear is prose') }) @@ -181,67 +156,57 @@ describe('MobileNativeChatComposer', () => { invokeAction: vi.fn(), recordCommand: vi.fn() } - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: 'run the tests', - onChangeText: vi.fn(), - onSend, - sessionOptions: { isWorking: false, controller } - }) - ) - }) - const modelPill = (): { props: { accessibilityState: { disabled: boolean } } } => - renderer!.root.find( - (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Model, Model' - ) as { props: { accessibilityState: { disabled: boolean } } } - expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false }) - // Start the send but don't await it — it stays in flight on purpose. - let pressed!: Promise - await act(async () => { - pressed = sendButton().props.onPress() - await Promise.resolve() - }) - expect(onSend).toHaveBeenCalled() - expect(modelPill().props.accessibilityState).toMatchObject({ disabled: true }) - await act(async () => { - releaseSend?.(true) - await pressed - }) - expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: 'run the tests', + onChangeText: vi.fn(), + onSend, + sessionOptions: { isWorking: false, controller } + }) + ) + }) + const modelPill = (): { props: { accessibilityState: { disabled: boolean } } } => + renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Model, Model' + ) as { props: { accessibilityState: { disabled: boolean } } } + expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false }) + // Start the send but don't await it — it stays in flight on purpose. + let pressed!: Promise + await act(async () => { + pressed = sendButton().props.onPress() + await Promise.resolve() + }) + expect(onSend).toHaveBeenCalled() + expect(modelPill().props.accessibilityState).toMatchObject({ disabled: true }) + await act(async () => { + releaseSend?.(true) + await pressed + }) + expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false }) }) it('blocks composer submission while a session-option command is pending', async () => { const onSend = vi.fn().mockResolvedValue(true) - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: 'hello', - onChangeText: vi.fn(), - onSend, - sessionOptions: { - isWorking: false, - controller: { - snapshot: [], - pendingId: 'model', - setOption: vi.fn(), - invokeAction: vi.fn(), - recordCommand: vi.fn() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: 'hello', + onChangeText: vi.fn(), + onSend, + sessionOptions: { + isWorking: false, + controller: { + snapshot: [], + pendingId: 'model', + setOption: vi.fn(), + invokeAction: vi.fn(), + recordCommand: vi.fn() } - }) - ) - }) - } finally { - restore() - } + } + }) + ) + }) expect(sendButton().props).toMatchObject({ disabled: true }) await act(async () => sendButton().props.onPress()) expect(onSend).not.toHaveBeenCalled() @@ -268,21 +233,16 @@ describe('MobileNativeChatComposer', () => { }) it('keeps the text input editable while the send is locked', async () => { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: 'half-typed', - onChangeText: vi.fn(), - onSend: vi.fn().mockResolvedValue(true), - disabled: true - }) - ) - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: 'half-typed', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + disabled: true + }) + ) + }) // Revoking `editable` on a focused field resigns first responder on iOS and // yanks the keyboard mid-typing (#10681) — the lock may only gate sending. const input = renderer!.root.find((node) => node.type === 'TextInput') as { @@ -294,25 +254,20 @@ describe('MobileNativeChatComposer', () => { it('renders a removable thumbnail for each pending image attachment', async () => { const onRemoveAttachment = vi.fn() - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: '', - onChangeText: vi.fn(), - onSend: vi.fn().mockResolvedValue(true), - attachments: [ - { id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }, - { id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' } - ], - onRemoveAttachment - }) - ) - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + attachments: [ + { id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }, + { id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' } + ], + onRemoveAttachment + }) + ) + }) const thumbs = renderer!.root.findAll((node) => node.type === 'Image') as Array<{ props: { source: { uri: string } } }> @@ -327,42 +282,32 @@ describe('MobileNativeChatComposer', () => { it('enables send with an attached image even when the text is empty', async () => { const onSend = vi.fn().mockResolvedValue(true) - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: '', - onChangeText: vi.fn(), - onSend, - attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }] - }) - ) - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend, + attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }] + }) + ) + }) expect(sendButton().props).toMatchObject({ disabled: false }) await act(async () => sendButton().props.onPress()) expect(onSend).toHaveBeenCalledWith('') }) it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: '/c', - onChangeText: vi.fn(), - onSend: vi.fn().mockResolvedValue(true), - agent: 'claude' - }) - ) - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '/c', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + agent: 'claude' + }) + ) + }) const input = () => renderer!.root.find((node) => node.type === 'TextInput') as { props: { @@ -390,21 +335,16 @@ describe('MobileNativeChatComposer', () => { }) it('serves the active agent’s shared command catalog with descriptions', async () => { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: '/', - onChangeText: vi.fn(), - onSend: vi.fn().mockResolvedValue(true), - agent: 'codex' - }) - ) - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '/', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + agent: 'codex' + }) + ) + }) const input = renderer!.root.find((node) => node.type === 'TextInput') as { props: { onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void } } @@ -428,45 +368,40 @@ describe('MobileNativeChatComposer', () => { (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Dictate' ) as { props: { onPress?: unknown; onPressIn?: unknown; onPressOut?: unknown } } - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatComposer, { - value: '', - onChangeText: vi.fn(), - onSend: vi.fn().mockResolvedValue(true), - onMicPress, - dictationMode: 'hold', - onMicPressIn, - onMicPressOut - }) - ) - }) - // Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert. - expect(mic().props.onPress).toBeUndefined() - expect(mic().props.onPressIn).toBe(onMicPressIn) - expect(mic().props.onPressOut).toBe(onMicPressOut) + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + onMicPress, + dictationMode: 'hold', + onMicPressIn, + onMicPressOut + }) + ) + }) + // Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert. + expect(mic().props.onPress).toBeUndefined() + expect(mic().props.onPressIn).toBe(onMicPressIn) + expect(mic().props.onPressOut).toBe(onMicPressOut) - await act(async () => { - renderer!.update( - createElement(MobileNativeChatComposer, { - value: '', - onChangeText: vi.fn(), - onSend: vi.fn().mockResolvedValue(true), - onMicPress, - dictationMode: 'toggle', - onMicPressIn, - onMicPressOut - }) - ) - }) - // Toggle mode: tap drives dictation, press-in/out inert. - expect(mic().props.onPress).toBe(onMicPress) - expect(mic().props.onPressIn).toBeUndefined() - expect(mic().props.onPressOut).toBeUndefined() - } finally { - restore() - } + await act(async () => { + renderer!.update( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + onMicPress, + dictationMode: 'toggle', + onMicPressIn, + onMicPressOut + }) + ) + }) + // Toggle mode: tap drives dictation, press-in/out inert. + expect(mic().props.onPress).toBe(onMicPress) + expect(mic().props.onPressIn).toBeUndefined() + expect(mic().props.onPressOut).toBeUndefined() }) }) diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 9d33db069ff..bb2335c7054 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { MAX_TOOL_DETAIL_LENGTH } from './mobile-native-chat-tool-summary' @@ -38,9 +38,6 @@ function toolMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage { describe('MobileNativeChatMessage', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) afterEach(() => { act(() => renderer?.unmount()) renderer = null @@ -50,20 +47,9 @@ describe('MobileNativeChatMessage', () => { message: NativeChatMessage, props: { toolsExpanded?: boolean } = {} ): ReactTestRenderer { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { - if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { - return - } - original(...a) + act(() => { + renderer = create(createElement(MobileNativeChatMessage, { message, ...props })) }) - try { - act(() => { - renderer = create(createElement(MobileNativeChatMessage, { message, ...props })) - }) - } finally { - spy.mockRestore() - } return renderer! } diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index c8e62c146cb..b43a7c79f3b 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileNativeChatOverlay } from './MobileNativeChatOverlay' import type { MobileNativeChatController } from './use-mobile-native-chat-controller' @@ -16,17 +16,6 @@ function assistantTurn(id: string, text: string): NativeChatMessage { return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } } -function suppressRendererWarning(): () => void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - return () => spy.mockRestore() -} - /** One render of the route: chat visible or not, the transcript it currently * holds, and the agent-status stream behind it. */ type Tick = { @@ -69,24 +58,15 @@ function overlayElement(tick: Tick): ReturnType { describe('MobileNativeChatOverlay streaming gate', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null }) async function render(tick: Tick): Promise { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(overlayElement(tick)) - }) - } finally { - restore() - } + await act(async () => { + renderer = create(overlayElement(tick)) + }) } async function update(tick: Tick): Promise { diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts index 8e0223f19ec..b39188b381f 100644 --- a/mobile/src/session/MobileNativeChatPermission.test.ts +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { MobileNativeChatPermission } from './MobileNativeChatPermission' vi.mock('react-native', () => ({ @@ -15,10 +15,6 @@ vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion' })) describe('MobileNativeChatPermission', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null @@ -28,25 +24,14 @@ describe('MobileNativeChatPermission', () => { let resolveResponse: (accepted: boolean) => void = () => {} const response = new Promise((resolve) => (resolveResponse = resolve)) const onRespond = vi.fn(() => response) - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create( + createElement(MobileNativeChatPermission, { + permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }, + onRespond + }) + ) }) - try { - await act(async () => { - renderer = create( - createElement(MobileNativeChatPermission, { - permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }, - onRespond - }) - ) - }) - } finally { - consoleSpy.mockRestore() - } const button = renderer.root.findByType('Pressable') act(() => { diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts index f5af434fb92..4430c60115a 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts @@ -149,7 +149,6 @@ describe('MobileNativeChatSessionOptionPickers', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true setOption.mockReset() setOption.mockResolvedValue(true) invokeAction.mockReset() diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 21a5a7c4aa1..fb4072c4f2d 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileNativeChatView } from './MobileNativeChatView' @@ -73,17 +73,6 @@ type Overrides = { onSend?: (text: string) => Promise } -function suppressRendererWarning(): () => void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - return () => spy.mockRestore() -} - function assistantTurn(id: string, text: string): NativeChatMessage { return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } } @@ -105,24 +94,15 @@ function chatViewElement(overrides: Overrides): ReturnType describe('MobileNativeChatView', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null }) async function render(overrides: Overrides = {}): Promise { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(chatViewElement(overrides)) - }) - } finally { - restore() - } + await act(async () => { + renderer = create(chatViewElement(overrides)) + }) } async function update(overrides: Overrides = {}): Promise { diff --git a/mobile/src/session/QuickCommandsList.test.ts b/mobile/src/session/QuickCommandsList.test.ts index eb2e2c787c0..a396932acb6 100644 --- a/mobile/src/session/QuickCommandsList.test.ts +++ b/mobile/src/session/QuickCommandsList.test.ts @@ -29,9 +29,7 @@ vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentI describe('QuickCommandsList search', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) + beforeEach(() => {}) afterEach(() => { act(() => renderer?.unmount()) diff --git a/mobile/src/session/QuickCommandsSheet.test.ts b/mobile/src/session/QuickCommandsSheet.test.ts index 654882cd2ad..9951742b64f 100644 --- a/mobile/src/session/QuickCommandsSheet.test.ts +++ b/mobile/src/session/QuickCommandsSheet.test.ts @@ -1,6 +1,6 @@ import { createElement, type ReactNode } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalQuickCommand } from '../../../src/shared/types' import type { RpcClient } from '../transport/rpc-client' import { MAX_QUICK_COMMANDS } from '../terminal/quick-commands' @@ -65,26 +65,15 @@ function deferred() { describe('QuickCommandsSheet', () => { let renderer: ReactTestRenderer | null = null - let consoleSpy: MockInstance - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true mocks.alert.mockReset() mocks.commands = [] mocks.persist.mockReset() - const originalConsoleError = console.error - consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) }) afterEach(() => { act(() => renderer?.unmount()) renderer = null - consoleSpy.mockRestore() }) it('keeps the sheet open when a launch is rejected', async () => { diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts index 253f8c8b85a..f74289d97ab 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.test.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts @@ -67,7 +67,6 @@ describe('useMobileNativeChatPermissionSend', () => { let respond: ((text: string) => Promise) | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true resetMobileNativeChatStaleInputForTests() resetMobileNativeChatTerminalWritesForTests() }) diff --git a/mobile/src/session/use-initial-session-terminal-autocreate.test.ts b/mobile/src/session/use-initial-session-terminal-autocreate.test.ts index 46a7bb859fa..5e4c8d425a4 100644 --- a/mobile/src/session/use-initial-session-terminal-autocreate.test.ts +++ b/mobile/src/session/use-initial-session-terminal-autocreate.test.ts @@ -52,7 +52,6 @@ describe('useInitialSessionTerminalAutoCreate', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true stateRef = { current: { autoCreatedForWorktree: null, sawSessionTabs: false } } diff --git a/mobile/src/session/use-live-worktree-name.test.ts b/mobile/src/session/use-live-worktree-name.test.ts index 45e64205d1d..ad15bda3cb7 100644 --- a/mobile/src/session/use-live-worktree-name.test.ts +++ b/mobile/src/session/use-live-worktree-name.test.ts @@ -13,18 +13,6 @@ vi.mock('expo-router', async () => { } }) -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - describe('useLiveWorktreeName request volume', () => { let renderer: ReactTestRenderer | null = null let eventListener: ((payload: unknown) => void) | null = null @@ -54,15 +42,10 @@ describe('useLiveWorktreeName request volume', () => { return null } - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(Harness)) - await Promise.resolve() - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) } async function emitEvent(payload: unknown): Promise { @@ -75,7 +58,6 @@ describe('useLiveWorktreeName request volume', () => { beforeEach(() => { vi.useFakeTimers() - globalThis.IS_REACT_ACT_ENVIRONMENT = true eventListener = null sendRequest.mockClear().mockResolvedValue({ id: 'worktree-show', @@ -179,15 +161,10 @@ describe('useLiveWorktreeName request volume', () => { return null } - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(FloatingHarness)) - await Promise.resolve() - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(FloatingHarness)) + await Promise.resolve() + }) await act(async () => { await vi.advanceTimersByTimeAsync(30_000) }) @@ -214,7 +191,6 @@ describe('useLiveWorktreeName request volume', () => { return null } - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create( @@ -241,7 +217,6 @@ describe('useLiveWorktreeName request volume', () => { ) }) } finally { - restoreConsoleError() act(() => renderer?.unmount()) } @@ -263,15 +238,10 @@ describe('useLiveWorktreeName request volume', () => { return null } const mount = async (): Promise => { - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(VerdictHarness)) - await Promise.resolve() - }) - } finally { - restoreConsoleError() - } + await act(async () => { + renderer = create(createElement(VerdictHarness)) + await Promise.resolve() + }) } await mount() diff --git a/mobile/src/session/use-missing-worktree-bounce.test.ts b/mobile/src/session/use-missing-worktree-bounce.test.ts index 209486bc8d1..df1bccc5678 100644 --- a/mobile/src/session/use-missing-worktree-bounce.test.ts +++ b/mobile/src/session/use-missing-worktree-bounce.test.ts @@ -27,7 +27,6 @@ describe('useMissingWorktreeBounce', () => { const bounce = vi.fn() beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true bounce.mockReset() renderer = null }) diff --git a/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts b/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts index a69d66accdd..9b11ad70c6d 100644 --- a/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts +++ b/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts @@ -7,16 +7,7 @@ type Gate = (targetHandle: string) => Promise describe('useMobileAttachmentInputLeaseGate', () => { let renderer: ReactTestRenderer | null = null - let errorSpy: ReturnType | null = null - beforeEach(() => { - const original = console.error - errorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...(args as Parameters)) - }) vi.useFakeTimers() }) @@ -24,7 +15,6 @@ describe('useMobileAttachmentInputLeaseGate', () => { act(() => renderer?.unmount()) renderer = null vi.useRealTimers() - errorSpy?.mockRestore() }) function renderGate(args: { diff --git a/mobile/src/session/use-mobile-default-session-view-preference.test.ts b/mobile/src/session/use-mobile-default-session-view-preference.test.ts index eb2624efd36..084164b6a40 100644 --- a/mobile/src/session/use-mobile-default-session-view-preference.test.ts +++ b/mobile/src/session/use-mobile-default-session-view-preference.test.ts @@ -30,7 +30,6 @@ describe('useMobileDefaultSessionViewPreference', () => { let preference: MobileDefaultSessionViewPreference | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true vi.mocked(loadDefaultSessionView).mockReset().mockResolvedValue('terminal') vi.mocked(saveDefaultSessionView).mockReset().mockResolvedValue(undefined) }) diff --git a/mobile/src/session/use-mobile-diff-review-controller.test.ts b/mobile/src/session/use-mobile-diff-review-controller.test.ts index 7c0a80455bc..5056b3cb546 100644 --- a/mobile/src/session/use-mobile-diff-review-controller.test.ts +++ b/mobile/src/session/use-mobile-diff-review-controller.test.ts @@ -63,7 +63,6 @@ describe('useMobileDiffReviewController', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true loadSnapshot.mockReset() }) diff --git a/mobile/src/session/use-mobile-diff-review-diff-loading.test.ts b/mobile/src/session/use-mobile-diff-review-diff-loading.test.ts index 3245c228254..4abb7443188 100644 --- a/mobile/src/session/use-mobile-diff-review-diff-loading.test.ts +++ b/mobile/src/session/use-mobile-diff-review-diff-loading.test.ts @@ -57,7 +57,6 @@ describe('useMobileDiffReviewDiffLoading', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true loadDiff.mockReset() }) diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts index f7594fd6e57..1d06190948c 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts @@ -52,7 +52,6 @@ describe('useMobileDiffReviewSendActions', () => { let saveCommentsAndReviewState: ReturnType beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true resetMobileNativeChatStaleInputForTests() setActionError = vi.fn() setSendSheet = vi.fn() @@ -81,20 +80,9 @@ describe('useMobileDiffReviewSendActions', () => { async function mount(client: RpcClient): Promise { mountedClient = client - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness)) }) - try { - await act(async () => { - renderer = create(createElement(Harness)) - }) - } finally { - consoleSpy.mockRestore() - } } it('heals a marked terminal BEFORE submitting the notes', async () => { diff --git a/mobile/src/session/use-mobile-file-tap-handlers.test.ts b/mobile/src/session/use-mobile-file-tap-handlers.test.ts index 6f48b2a771d..0cfa617f269 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.test.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.test.ts @@ -19,7 +19,6 @@ describe('useMobileFileTapHandlers', () => { let handlers: Handlers | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true push.mockClear() }) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts index b296ea4851b..64fa30aecc1 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -54,7 +54,6 @@ describe('useMobileNativeChatAnswerSend', () => { beforeEach(() => { onAccepted = vi.fn() vi.useFakeTimers() - globalThis.IS_REACT_ACT_ENVIRONMENT = true resetMobileNativeChatStaleInputForTests() resetMobileNativeChatTerminalWritesForTests() }) @@ -93,20 +92,9 @@ describe('useMobileNativeChatAnswerSend', () => { mountedClient = client mountedOnSendError = onSendError mountedAgent = agent - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { enabled: true })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { enabled: true })) - }) - } finally { - consoleSpy.mockRestore() - } } async function setEnabled(enabled: boolean): Promise { diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts index 460ffca54cd..de9d53fb094 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AskPrompt } from './mobile-native-chat-ask' import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' @@ -10,7 +10,6 @@ describe('useMobileNativeChatAskDismiss', () => { let renders = 0 beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true renders = 0 }) @@ -45,20 +44,9 @@ describe('useMobileNativeChatAskDismiss', () => { } async function mount(props: Parameters[0]): Promise { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, props)) }) - try { - await act(async () => { - renderer = create(createElement(Harness, props)) - }) - } finally { - consoleSpy.mockRestore() - } } async function update(props: Parameters[0]): Promise { diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index a9cd1201639..c04b560798d 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -127,24 +127,12 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true vi.clearAllMocks() resetMobileNativeChatStaleInputForTests() captureSendOrigin.mockReturnValue(ORIGIN) - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { - if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { - return - } - original(...a) + act(() => { + renderer = create(createElement(Harness)) }) - try { - act(() => { - renderer = create(createElement(Harness)) - }) - } finally { - spy.mockRestore() - } }) afterEach(() => { act(() => renderer?.unmount()) @@ -364,24 +352,12 @@ describe('useMobileNativeChatController launch-draft wiring', () => { } function render(tab: unknown): void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { - if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { - return - } - original(...a) + act(() => { + renderer = create(createElement(Harness, { tab })) }) - try { - act(() => { - renderer = create(createElement(Harness, { tab })) - }) - } finally { - spy.mockRestore() - } } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true draftsArgs.length = 0 viewMode.isTabChatView = () => true sessionState.messages = [] @@ -487,25 +463,13 @@ describe('useMobileNativeChatController ask dismissal across a transcript reload } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true viewMode.isTabChatView = () => true setTranscript('ready') promptsState.ask = PROMPT promptsState.detectedAsk = PROMPT - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { - if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { - return - } - original(...a) + act(() => { + renderer = create(createElement(Harness)) }) - try { - act(() => { - renderer = create(createElement(Harness)) - }) - } finally { - spy.mockRestore() - } }) afterEach(() => { @@ -757,22 +721,10 @@ describe('useMobileNativeChatController streaming scope', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true viewMode.isTabChatView = () => true - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { - if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { - return - } - original(...a) + act(() => { + renderer = create(createElement(Harness)) }) - try { - act(() => { - renderer = create(createElement(Harness)) - }) - } finally { - spy.mockRestore() - } }) afterEach(() => { diff --git a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts index 91c84814c41..665d3ac1f2f 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' @@ -23,10 +23,6 @@ describe('useMobileNativeChatDrafts launch draft', () => { let renderer: ReactTestRenderer | null = null let state: DraftState | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null @@ -65,20 +61,9 @@ describe('useMobileNativeChatDrafts launch draft', () => { } async function mount(tabId: string): Promise { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { tabId })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { tabId })) - }) - } finally { - consoleSpy.mockRestore() - } } it('prefills the composer from a host launch draft exactly once', async () => { diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index 154ef3fb870..db8e76696d0 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' @@ -30,10 +30,6 @@ describe('useMobileNativeChatDrafts', () => { let renderer: ReactTestRenderer | null = null let state: DraftState | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null @@ -69,20 +65,9 @@ describe('useMobileNativeChatDrafts', () => { } async function mount(tabId: string): Promise { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { tabId })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { tabId })) - }) - } finally { - consoleSpy.mockRestore() - } } async function switchTo(tabId: string): Promise { diff --git a/mobile/src/session/use-mobile-native-chat-file-search.test.ts b/mobile/src/session/use-mobile-native-chat-file-search.test.ts index e0500019844..93db87c8b32 100644 --- a/mobile/src/session/use-mobile-native-chat-file-search.test.ts +++ b/mobile/src/session/use-mobile-native-chat-file-search.test.ts @@ -15,17 +15,6 @@ function rpcSuccess(files: string[]): Awaited void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - return () => spy.mockRestore() -} - describe('useMobileNativeChatFileSearch', () => { let renderer: ReactTestRenderer | null = null let state: SearchState | null = null @@ -35,19 +24,13 @@ describe('useMobileNativeChatFileSearch', () => { state = useMobileNativeChatFileSearch({ client, worktreeId: 'wt-1' }) return null } - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(createElement(Harness)) - }) - } finally { - restore() - } + await act(async () => { + renderer = create(createElement(Harness)) + }) } beforeEach(() => { vi.useFakeTimers() - globalThis.IS_REACT_ACT_ENVIRONMENT = true state = null }) diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index a57e1c7520c..cb8522bae7a 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -87,7 +87,6 @@ describe('useMobileNativeChatImageAttachments', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true pick.mockReset() // Stale markers and write locks live at module scope (they outlive the // screen), so they also outlive a test. @@ -101,20 +100,9 @@ describe('useMobileNativeChatImageAttachments', () => { }) function mount(args: HookArgs): void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { - if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { - return - } - original(...a) + act(() => { + renderer = create(createElement(Harness, { args })) }) - try { - act(() => { - renderer = create(createElement(Harness, { args })) - }) - } finally { - spy.mockRestore() - } } function update(args: HookArgs): void { diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts index c7e60170f0a..7491f74500f 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { useMobileNativeChatInputLease } from './use-mobile-native-chat-input-lease' type Lease = ReturnType @@ -9,10 +9,6 @@ describe('useMobileNativeChatInputLease', () => { let renderer: ReactTestRenderer | null = null let lease: Lease | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null @@ -25,65 +21,43 @@ describe('useMobileNativeChatInputLease', () => { } it('unlocks only after acknowledgement and clears on disconnect', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { connected: true })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { connected: true })) - }) - expect(lease?.ready).toBe(false) - expect(lease?.lockReason).toBe('waiting') - act(() => lease?.markReady('terminal')) - expect(lease?.ready).toBe(true) - expect(lease?.lockReason).toBeNull() + expect(lease?.ready).toBe(false) + expect(lease?.lockReason).toBe('waiting') + act(() => lease?.markReady('terminal')) + expect(lease?.ready).toBe(true) + expect(lease?.lockReason).toBeNull() - act(() => lease?.clear()) - expect(lease?.ready).toBe(false) - act(() => lease?.markReady('terminal')) - expect(lease?.ready).toBe(true) + act(() => lease?.clear()) + expect(lease?.ready).toBe(false) + act(() => lease?.markReady('terminal')) + expect(lease?.ready).toBe(true) - await act(async () => renderer?.update(createElement(Harness, { connected: false }))) - expect(lease?.ready).toBe(false) - expect(lease?.lockReason).toBe('disconnected') - } finally { - consoleSpy.mockRestore() - } + await act(async () => renderer?.update(createElement(Harness, { connected: false }))) + expect(lease?.ready).toBe(false) + expect(lease?.lockReason).toBe('disconnected') }) it('reports whether a clear actually dropped a lease', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { connected: true })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { connected: true })) - }) - // The route reads this to tell a real teardown from one React never sees. - expect(lease?.clear('terminal')).toBe(false) - expect(lease?.clear()).toBe(false) + // The route reads this to tell a real teardown from one React never sees. + expect(lease?.clear('terminal')).toBe(false) + expect(lease?.clear()).toBe(false) - act(() => lease?.markReady('terminal')) - let dropped: boolean | undefined - act(() => { - dropped = lease?.clear('terminal') - }) - expect(dropped).toBe(true) - expect(lease?.ready).toBe(false) - expect(lease?.clear('terminal')).toBe(false) + act(() => lease?.markReady('terminal')) + let dropped: boolean | undefined + act(() => { + dropped = lease?.clear('terminal') + }) + expect(dropped).toBe(true) + expect(lease?.ready).toBe(false) + expect(lease?.clear('terminal')).toBe(false) - act(() => lease?.markReady('other')) - expect(lease?.clear()).toBe(true) - } finally { - consoleSpy.mockRestore() - } + act(() => lease?.markReady('other')) + expect(lease?.clear()).toBe(true) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 0ceda5714da..06b30f5cdb4 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -80,7 +80,6 @@ describe('useMobileNativeChatMessageSend', () => { (clearInputWrite.mock.calls[0]?.[0] ?? {}) as { clearInput?: string } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true sendWithOutcome.mockReset() sendWithOutcome.mockResolvedValue('accepted') clearInputWrite.mockReset() diff --git a/mobile/src/session/use-mobile-native-chat-readability.test.ts b/mobile/src/session/use-mobile-native-chat-readability.test.ts index 41d08c3c32c..b7e2c5daab3 100644 --- a/mobile/src/session/use-mobile-native-chat-readability.test.ts +++ b/mobile/src/session/use-mobile-native-chat-readability.test.ts @@ -10,7 +10,6 @@ describe('useMobileNativeChatReadability', () => { let readable = false beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true readable = false }) @@ -34,21 +33,10 @@ describe('useMobileNativeChatReadability', () => { readable = useMobileNativeChatReadability(client, worktreeId) return null } - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() }) - try { - await act(async () => { - renderer = create(createElement(Harness)) - await Promise.resolve() - }) - } finally { - consoleSpy.mockRestore() - } return sendRequest } @@ -89,32 +77,21 @@ describe('useMobileNativeChatReadability', () => { readable = useMobileNativeChatReadability(client, worktreeId) return null } - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { worktreeId: 'local-repo::/one' })) + await Promise.resolve() }) - try { - await act(async () => { - renderer = create(createElement(Harness, { worktreeId: 'local-repo::/one' })) - await Promise.resolve() - }) - expect(readable).toBe(true) + expect(readable).toBe(true) - act(() => renderer?.update(createElement(Harness, { worktreeId: 'ssh-repo::/two' }))) - expect(readable).toBe(false) - await act(async () => { - resolveNext({ - ok: true, - result: { repos: [{ id: 'ssh-repo', connectionId: 'model-a-ssh' }] } - }) - await Promise.resolve() + act(() => renderer?.update(createElement(Harness, { worktreeId: 'ssh-repo::/two' }))) + expect(readable).toBe(false) + await act(async () => { + resolveNext({ + ok: true, + result: { repos: [{ id: 'ssh-repo', connectionId: 'model-a-ssh' }] } }) - expect(readable).toBe(false) - } finally { - consoleSpy.mockRestore() - } + await Promise.resolve() + }) + expect(readable).toBe(false) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-send-error.test.ts b/mobile/src/session/use-mobile-native-chat-send-error.test.ts index a22eee3a0a4..49ca07198d0 100644 --- a/mobile/src/session/use-mobile-native-chat-send-error.test.ts +++ b/mobile/src/session/use-mobile-native-chat-send-error.test.ts @@ -31,31 +31,13 @@ describe('useMobileNativeChatSendError', () => { return apiRef.current } - /** react-test-renderer logs a deprecation notice on every render; keep real errors. */ - function suppressRendererWarning(): () => void { - const original = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - return () => spy.mockRestore() - } - async function render(scopeKey: string | null = 'terminal-1'): Promise { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(createElement(Harness, { scopeKey })) - }) - } finally { - restore() - } + await act(async () => { + renderer = create(createElement(Harness, { scopeKey })) + }) } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true apiRef.current = null showToast.mockClear() vi.useFakeTimers() @@ -117,26 +99,16 @@ describe('useMobileNativeChatSendError', () => { await act(async () => api().show('a')) expect(api().message).toBe('a') - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) - }) - } finally { - restore() - } + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) + }) expect(api().message).toBeNull() }) it('falls back to the toast when the banner is not mounted', async () => { - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(createElement(Harness, { scopeKey: 'terminal-1', bannerMounted: false })) - }) - } finally { - restore() - } + await act(async () => { + renderer = create(createElement(Harness, { scopeKey: 'terminal-1', bannerMounted: false })) + }) // A deferred failure landing after the user left chat must still be seen. await act(async () => api().show('Delivery unconfirmed')) @@ -149,14 +121,9 @@ describe('useMobileNativeChatSendError', () => { // Captured while tab A was live; a 20s unconfirmed send resolves much later. const showFromTabA = api().show - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) - }) - } finally { - restore() - } + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) + }) await act(async () => showFromTabA('Message not sent')) // The banner belongs to terminal-2 now, so A's failure must not paint there. @@ -168,14 +135,9 @@ describe('useMobileNativeChatSendError', () => { await render('terminal-1') const clearFromTabA = api().clear - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) - }) - } finally { - restore() - } + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) + }) await act(async () => api().show('b')) // An accepted card action from tab A resolving late must not retire B's warning. await act(async () => clearFromTabA()) diff --git a/mobile/src/session/use-mobile-native-chat-session-options.test.ts b/mobile/src/session/use-mobile-native-chat-session-options.test.ts index 246c3021948..d7cab9a4c37 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.test.ts @@ -44,7 +44,6 @@ describe('useMobileNativeChatSessionOptions', () => { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true clearMobileSessionOptionRecordsForTests() dispatchCommand.mockReset() dispatchCommand.mockResolvedValue('accepted') diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 8c685d00202..dfe5f285937 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -24,7 +24,6 @@ describe('useMobileNativeChatSession', () => { let emit: (frame: unknown) => void = () => {} beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true state = null }) @@ -45,20 +44,9 @@ describe('useMobileNativeChatSession', () => { } async function mount(client: RpcClient): Promise { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { client })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { client })) - }) - } finally { - consoleSpy.mockRestore() - } } it('drops an older-page response captured before transcript replacement', async () => { @@ -445,7 +433,6 @@ describe('useMobileNativeChatSession transcriptLoading', () => { }[] = [] beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true renders.length = 0 }) @@ -482,20 +469,9 @@ describe('useMobileNativeChatSession transcriptLoading', () => { } async function mountAt(client: RpcClient | null, sessionId: string | null): Promise { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { client, sessionId })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { client, sessionId })) - }) - } finally { - consoleSpy.mockRestore() - } } it('reports loading on the very first render, before the subscription effect runs', async () => { diff --git a/mobile/src/session/use-mobile-native-chat-stop.test.ts b/mobile/src/session/use-mobile-native-chat-stop.test.ts index 2a5460b67fa..bdc405cb57d 100644 --- a/mobile/src/session/use-mobile-native-chat-stop.test.ts +++ b/mobile/src/session/use-mobile-native-chat-stop.test.ts @@ -14,7 +14,6 @@ describe('useMobileNativeChatStop', () => { beforeEach(() => { vi.useFakeTimers() - globalThis.IS_REACT_ACT_ENVIRONMENT = true sendRequest.mockReset().mockResolvedValue({ ok: true, result: { send: { accepted: true } } diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts index fdfb5041402..ce282fcd5b1 100644 --- a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts @@ -17,7 +17,6 @@ describe('useMobileNativeChatTerminalStream', () => { const hasTabsRecoveryNeedRef = { current: (): boolean => false } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true subscriptionsRef.current = new Map([['terminal-1', () => {}]]) subscribingRef.current = new Set() webReadyRef.current = new Set(['terminal-1']) @@ -85,113 +84,73 @@ describe('useMobileNativeChatTerminalStream', () => { } it('replaces output with a lease-only stream while covered, then restores output', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: false })) + }) + await act(async () => { + notifyWebReadyRef.current('terminal-1', false) + }) + expect(harnessRenderCount).toBe(1) + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: true })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: false })) - }) - await act(async () => { - notifyWebReadyRef.current('terminal-1', false) - }) - expect(harnessRenderCount).toBe(1) - await act(async () => { - renderer?.update(createElement(Harness, { showNativeChat: true })) - }) - expect(unsubscribe).toHaveBeenNthCalledWith(1, 'terminal-1') - expect(subscribe).toHaveBeenNthCalledWith(1, 'terminal-1') - expect(initializedRef.current.has('terminal-1')).toBe(false) + expect(unsubscribe).toHaveBeenNthCalledWith(1, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(1, 'terminal-1') + expect(initializedRef.current.has('terminal-1')).toBe(false) - await act(async () => { - renderer?.update(createElement(Harness, { showNativeChat: false })) - }) + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: false })) + }) - expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') - expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') - } finally { - consoleSpy.mockRestore() - } + expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') }) it('re-subscribes a covered stream torn down under chat (#10681)', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + unsubscribe.mockClear() + // What a terminal.list prune / `end` frame does to the lease-only stream: + // the subscription is gone and the lease drops with it. + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: true, leaseReady: false })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - subscribe.mockClear() - unsubscribe.mockClear() - // What a terminal.list prune / `end` frame does to the lease-only stream: - // the subscription is gone and the lease drops with it. - subscriptionsRef.current.delete('terminal-1') - await act(async () => { - renderer?.update(createElement(Harness, { showNativeChat: true, leaseReady: false })) - }) - expect(subscribe).toHaveBeenCalledOnce() - expect(subscribe).toHaveBeenCalledWith('terminal-1') - // Pins the rearm branch specifically: without it the action falls through to - // the resume tail, which also subscribes — but drops coverage on the way. - expect(unsubscribe).not.toHaveBeenCalled() - } finally { - consoleSpy.mockRestore() - } + expect(subscribe).toHaveBeenCalledOnce() + expect(subscribe).toHaveBeenCalledWith('terminal-1') + // Pins the rearm branch specifically: without it the action falls through to + // the resume tail, which also subscribes — but drops coverage on the way. + expect(unsubscribe).not.toHaveBeenCalled() }) it('stops rearming a handle whose stream never comes back (#10681)', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) }) - try { + subscribe.mockClear() + // A dead PTY answers every subscribe with `subscribed`+`end`, so the stream is + // gone again on each pass. Unbounded, that is a ~10s resubscribe loop. + for (let revision = 1; revision <= 6; revision += 1) { + subscriptionsRef.current.delete('terminal-1') await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) + renderer?.update( + createElement(Harness, { + showNativeChat: true, + leaseReady: false, + streamRevision: revision + }) + ) }) - subscribe.mockClear() - // A dead PTY answers every subscribe with `subscribed`+`end`, so the stream is - // gone again on each pass. Unbounded, that is a ~10s resubscribe loop. - for (let revision = 1; revision <= 6; revision += 1) { - subscriptionsRef.current.delete('terminal-1') - await act(async () => { - renderer?.update( - createElement(Harness, { - showNativeChat: true, - leaseReady: false, - streamRevision: revision - }) - ) - }) - } - - expect(subscribe.mock.calls.length).toBeLessThanOrEqual(3) - } finally { - consoleSpy.mockRestore() } + + expect(subscribe.mock.calls.length).toBeLessThanOrEqual(3) }) it('does not charge the rearm budget for a subscribe its own gates turned away', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) try { await act(async () => { renderer = create(createElement(Harness, { showNativeChat: true })) @@ -218,89 +177,66 @@ describe('useMobileNativeChatTerminalStream', () => { subscribe.mockImplementation((handle: string) => subscriptionsRef.current.set(handle, () => {}) ) - consoleSpy.mockRestore() } }) it('refills the rearm budget when terminal.list reports the handle again (#10681)', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) }) - try { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - for (let revision = 1; revision <= 5; revision += 1) { - subscriptionsRef.current.delete('terminal-1') - await act(async () => { - renderer?.update( - createElement(Harness, { - showNativeChat: true, - leaseReady: false, - streamRevision: revision - }) - ) - }) - } - subscribe.mockClear() - // Budget is spent, so a further teardown signal alone changes nothing. - await act(async () => { - renderer?.update( - createElement(Harness, { showNativeChat: true, leaseReady: false, streamRevision: 6 }) - ) - }) - expect(subscribe).not.toHaveBeenCalled() - - // A dead-but-listed handle must not buy a new budget on every list refresh, - // or the resubscribe loop this bound exists to stop comes straight back. - await act(async () => { - notifyListedHandlesRef.current(new Set(['terminal-1'])) - notifyListedHandlesRef.current(new Set(['terminal-1'])) - }) - expect(subscribe).not.toHaveBeenCalled() - - // The handle actually went away and came back: it may have a live PTY once more. - await act(async () => { - notifyListedHandlesRef.current(new Set()) - notifyListedHandlesRef.current(new Set(['terminal-1'])) - }) - - expect(subscribe).toHaveBeenCalledWith('terminal-1') - } finally { - consoleSpy.mockRestore() - } - }) - - it('rearms on a teardown the lease cannot report (#10681)', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - try { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - subscribe.mockClear() - // `end` with no preceding `subscribed` leaves the lease untouched, so - // `leaseReady` holds its value and only the revision bump can re-run us. + for (let revision = 1; revision <= 5; revision += 1) { subscriptionsRef.current.delete('terminal-1') await act(async () => { renderer?.update( - createElement(Harness, { showNativeChat: true, leaseReady: true, streamRevision: 1 }) + createElement(Harness, { + showNativeChat: true, + leaseReady: false, + streamRevision: revision + }) ) }) - - expect(subscribe).toHaveBeenCalledWith('terminal-1') - } finally { - consoleSpy.mockRestore() } + subscribe.mockClear() + // Budget is spent, so a further teardown signal alone changes nothing. + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: true, leaseReady: false, streamRevision: 6 }) + ) + }) + expect(subscribe).not.toHaveBeenCalled() + + // A dead-but-listed handle must not buy a new budget on every list refresh, + // or the resubscribe loop this bound exists to stop comes straight back. + await act(async () => { + notifyListedHandlesRef.current(new Set(['terminal-1'])) + notifyListedHandlesRef.current(new Set(['terminal-1'])) + }) + expect(subscribe).not.toHaveBeenCalled() + + // The handle actually went away and came back: it may have a live PTY once more. + await act(async () => { + notifyListedHandlesRef.current(new Set()) + notifyListedHandlesRef.current(new Set(['terminal-1'])) + }) + + expect(subscribe).toHaveBeenCalledWith('terminal-1') + }) + + it('rearms on a teardown the lease cannot report (#10681)', async () => { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + // `end` with no preceding `subscribed` leaves the lease untouched, so + // `leaseReady` holds its value and only the revision bump can re-run us. + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: true, leaseReady: true, streamRevision: 1 }) + ) + }) + + expect(subscribe).toHaveBeenCalledWith('terminal-1') }) it('does not let a dead PTY buy a new budget with its own `subscribed` ack', async () => { @@ -411,34 +347,23 @@ describe('useMobileNativeChatTerminalStream', () => { }) it('resumes a cold-start lease-only stream when WebView readiness arrives late', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) + webReadyRef.current.clear() + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: false })) }) - try { - webReadyRef.current.clear() - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - await act(async () => { - renderer?.update(createElement(Harness, { showNativeChat: false })) - }) - expect(unsubscribe).toHaveBeenCalledOnce() - expect(subscribe).toHaveBeenCalledOnce() + expect(unsubscribe).toHaveBeenCalledOnce() + expect(subscribe).toHaveBeenCalledOnce() - webReadyRef.current.add('terminal-1') - await act(async () => { - notifyWebReadyRef.current('terminal-1', false) - }) + webReadyRef.current.add('terminal-1') + await act(async () => { + notifyWebReadyRef.current('terminal-1', false) + }) - expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') - expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') - } finally { - consoleSpy.mockRestore() - } + expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') }) }) diff --git a/mobile/src/session/use-mobile-session-tabs-reconciliation.test.ts b/mobile/src/session/use-mobile-session-tabs-reconciliation.test.ts index 2ec37155bda..33c507f2712 100644 --- a/mobile/src/session/use-mobile-session-tabs-reconciliation.test.ts +++ b/mobile/src/session/use-mobile-session-tabs-reconciliation.test.ts @@ -112,8 +112,6 @@ async function setAppState(state: string): Promise { describe('useMobileSessionTabsReconciliation', () => { let renderer: ReactTestRenderer | null = null - let consoleErrorSpy: ReturnType - async function mount(): Promise { await act(async () => { renderer = create(createElement(Harness)) @@ -124,14 +122,6 @@ describe('useMobileSessionTabsReconciliation', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(0) - globalThis.IS_REACT_ACT_ENVIRONMENT = true - const originalConsoleError = console.error - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) lifecycle.appState = 'active' lifecycle.focused = true lifecycle.listeners.clear() @@ -157,7 +147,6 @@ describe('useMobileSessionTabsReconciliation', () => { act(() => renderer?.unmount()) renderer = null streamListener = null - consoleErrorSpy.mockRestore() vi.useRealTimers() }) diff --git a/mobile/src/session/use-mobile-session-view-mode.test.ts b/mobile/src/session/use-mobile-session-view-mode.test.ts index 4315bda35bf..0eda383487c 100644 --- a/mobile/src/session/use-mobile-session-view-mode.test.ts +++ b/mobile/src/session/use-mobile-session-view-mode.test.ts @@ -52,7 +52,6 @@ describe('useMobileSessionViewMode', () => { let controller: MobileSessionViewModeController | null = null beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true vi.mocked(loadDefaultSessionView).mockReset().mockResolvedValue('terminal') vi.mocked(readSessionViewOverridesPreference) .mockReset() diff --git a/mobile/src/session/use-quick-commands.test.ts b/mobile/src/session/use-quick-commands.test.ts index 66fec5bf4e5..a4f93b326fc 100644 --- a/mobile/src/session/use-quick-commands.test.ts +++ b/mobile/src/session/use-quick-commands.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalQuickCommand } from '../../../src/shared/types' import type { RpcClient } from '../transport/rpc-client' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' @@ -49,24 +49,10 @@ function deferred() { describe('useQuickCommands', () => { let renderer: ReactTestRenderer | null = null let state: ReturnType | null = null - let consoleSpy: MockInstance - - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - const original = console.error - consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null state = null - consoleSpy.mockRestore() }) async function mount(client: RpcClient, enabled = true): Promise { diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.test.ts b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts index d95fab23698..0a98d737792 100644 --- a/mobile/src/session/use-terminal-live-input-mode-preference.test.ts +++ b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts @@ -34,18 +34,6 @@ function createDeferred(): Deferred { return { promise, resolve } } -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness { let current: ReturnType | null = null let renderer: ReactTestRenderer | null = null @@ -58,14 +46,9 @@ function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePr return null } - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - act(() => { - renderer = create(createElement(Harness)) - }) - } finally { - restoreConsoleError() - } + act(() => { + renderer = create(createElement(Harness)) + }) if (!current || !renderer) { throw new Error('terminal live input mode preference hook did not render') } diff --git a/mobile/src/session/use-throttled-latest-value.test.ts b/mobile/src/session/use-throttled-latest-value.test.ts index bdea29c7459..29e58c8fa35 100644 --- a/mobile/src/session/use-throttled-latest-value.test.ts +++ b/mobile/src/session/use-throttled-latest-value.test.ts @@ -1,12 +1,11 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useThrottledLatestValue } from './use-throttled-latest-value' describe('useThrottledLatestValue', () => { let renderer: ReactTestRenderer | null = null let latest: string | undefined - let consoleSpy: MockInstance function Harness({ value }: { value: string | undefined }): null { latest = useThrottledLatestValue(value, 50) @@ -25,22 +24,13 @@ describe('useThrottledLatestValue', () => { beforeEach(() => { vi.useFakeTimers() - globalThis.IS_REACT_ACT_ENVIRONMENT = true latest = undefined - const original = console.error - consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) }) afterEach(() => { act(() => renderer?.unmount()) renderer = null vi.useRealTimers() - consoleSpy.mockRestore() }) it('emits the first frame immediately', () => { diff --git a/mobile/src/source-control/MobileGitHistoryList.test.tsx b/mobile/src/source-control/MobileGitHistoryList.test.tsx index 50b0fa7fe73..b19a069c80f 100644 --- a/mobile/src/source-control/MobileGitHistoryList.test.tsx +++ b/mobile/src/source-control/MobileGitHistoryList.test.tsx @@ -1,6 +1,6 @@ import { createElement, type ReactElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { MobileGitHistoryList } from './MobileGitHistoryList' @@ -44,10 +44,6 @@ const compareResponse = { describe('MobileGitHistoryList', () => { let renderer: ReactTestRenderer | null = null - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts b/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts index 13109a610db..f6e3eab8873 100644 --- a/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts +++ b/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review' import { buildMobileHostedReviewEligibilityLoadKey, @@ -206,10 +206,6 @@ describe('eligibility request ordering', () => { let renderer: ReactTestRenderer | null = null let renderedState: MobileCreatePrEligibilityState = { kind: 'idle' } - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { act(() => renderer?.unmount()) renderer = null diff --git a/mobile/src/terminal/terminal-webview-engine-error.test.ts b/mobile/src/terminal/terminal-webview-engine-error.test.ts index 4e29c287321..2a1ba5df93d 100644 --- a/mobile/src/terminal/terminal-webview-engine-error.test.ts +++ b/mobile/src/terminal/terminal-webview-engine-error.test.ts @@ -41,18 +41,6 @@ vi.mock('lucide-react-native', () => ({ RefreshCw: 'RefreshCw' })) -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - // Why: mounting TerminalWebView arms the web-ready watchdog; tests must // unmount so the timer can't survive into later tests and fire in teardown. let activeRenderer: ReactTestRenderer | null = null @@ -62,14 +50,9 @@ function createTerminalWebViewRenderer( props: Record = {} ) { let renderer: ReactTestRenderer | null = null - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - act(() => { - renderer = create(createElement(TerminalWebView, { onEngineError, ...props })) - }) - } finally { - restoreConsoleError() - } + act(() => { + renderer = create(createElement(TerminalWebView, { onEngineError, ...props })) + }) if (!renderer) { throw new Error('TerminalWebView did not render') } diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts index 1ca5216c7a8..2542b315a8f 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts @@ -25,18 +25,6 @@ function createDeferredBoolean(): DeferredBoolean { return { promise, resolve: resolvePromise } } -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - type AccessoryInputCommitHarnessOptions = { readonly heldText?: string readonly sentText?: string @@ -105,14 +93,9 @@ function createAccessoryInputCommitHarness({ return null } - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - act(() => { - renderer = create(createElement(Harness)) - }) - } finally { - restoreConsoleError() - } + act(() => { + renderer = create(createElement(Harness)) + }) if (!commit || !renderer) { throw new Error('terminal live accessory input hook did not render') } diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index c211ff447ec..544d57a1bf1 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -20,18 +20,6 @@ type TerminalLiveInputCommitHarnessOptions = { readonly sendResult?: boolean } -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => consoleErrorSpy.mockRestore() -} - function createTerminalLiveInputCommitHarness({ sendResult = true }: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness { @@ -77,14 +65,9 @@ function createTerminalLiveInputCommitHarness({ return null } - const restoreConsoleError = suppressReactTestRendererDeprecationWarning() - try { - act(() => { - renderer = create(createElement(Harness)) - }) - } finally { - restoreConsoleError() - } + act(() => { + renderer = create(createElement(Harness)) + }) if (!handlers || !renderer) { throw new Error('terminal live input hook did not render') } diff --git a/mobile/src/terminal/use-terminal-live-input-focus.test.ts b/mobile/src/terminal/use-terminal-live-input-focus.test.ts index f1b6c5eadb9..92ce4e2c455 100644 --- a/mobile/src/terminal/use-terminal-live-input-focus.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-focus.test.ts @@ -1,6 +1,6 @@ import { createElement, type RefObject } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalLiveInputFocusTarget, TerminalLiveInputFocusTimerRef @@ -20,17 +20,6 @@ type HarnessProps = { type FocusHandlers = ReturnType -function suppressReactTestRendererWarning(): () => void { - const originalConsoleError = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => spy.mockRestore() -} - function createFocusTarget(initiallyFocused = false): TerminalLiveInputFocusTarget & { readonly blur: ReturnType readonly focus: ReturnType @@ -67,14 +56,9 @@ function createHarness(initialProps: HarnessProps): { return null } - const restoreWarning = suppressReactTestRendererWarning() - try { - act(() => { - renderer = create(createElement(Harness, initialProps)) - }) - } finally { - restoreWarning() - } + act(() => { + renderer = create(createElement(Harness, initialProps)) + }) if (!handlers || !renderer) { throw new Error('terminal live input focus harness did not render') } @@ -113,10 +97,6 @@ function connectedProps( } describe('terminal live input focus hook', () => { - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - }) - afterEach(() => { vi.useRealTimers() }) diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index b08899f5333..59b0a7ac5dc 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -71,18 +71,6 @@ type Harness = { readonly unmount: () => void } -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - const firstArg = args[0] - if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => spy.mockRestore() -} - async function renderHarness(hostId: string): Promise { let hook: ReturnType | null = null let closeHost: ((hostId: string) => void) | null = null @@ -94,14 +82,9 @@ async function renderHarness(hostId: string): Promise { return null } - const restore = suppressReactTestRendererDeprecationWarning() - try { - await act(async () => { - renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) - }) - } finally { - restore() - } + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) if (!hook || !closeHost || !renderer) { throw new Error('harness did not render') } @@ -124,7 +107,6 @@ async function renderHarness(hostId: string): Promise { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true connectMock.mockReset() loadHostsMock.mockReset() }) @@ -149,7 +131,6 @@ describe('useHostClient', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -169,7 +150,6 @@ describe('useHostClient', () => { expect(selectedState).toBe('disconnected') expect(connectMock).toHaveBeenCalledTimes(2) } finally { - restore() act(() => renderer?.unmount()) } }) @@ -188,7 +168,6 @@ describe('useHostClient', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -211,7 +190,6 @@ describe('useHostClient', () => { }) expect(stateByRenderTick.get(2)).toBe('connecting') } finally { - restore() act(() => renderer?.unmount()) } }) @@ -263,7 +241,6 @@ describe('useHostClient', () => { states.push(useHostClient(HOST.id).state) return null } - const restore = suppressReactTestRendererDeprecationWarning() try { act(() => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -277,7 +254,6 @@ describe('useHostClient', () => { expect(states.at(-1)).toBe('connecting') expect(states).not.toContain('disconnected') } finally { - restore() act(() => renderer?.unmount()) } }) @@ -296,7 +272,6 @@ describe('useHostClient', () => { states.push(useHostClient(HOST.id).state) return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -311,7 +286,6 @@ describe('useHostClient', () => { expect(states.at(-1)).toBe('connecting') expect(states).not.toContain('disconnected') } finally { - restore() act(() => renderer?.unmount()) } }) @@ -333,14 +307,9 @@ describe('useHostClient', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() - try { - act(() => { - renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) - }) - } finally { - restore() - } + act(() => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) expect(loadHostsMock).toHaveBeenCalledOnce() if (!closeHost || !resolveHosts || !renderer) { throw new Error('pending-open harness did not initialize') @@ -370,14 +339,9 @@ describe('useHostClient', () => { useHostClient(HOST.id) return null } - const restore = suppressReactTestRendererDeprecationWarning() - try { - act(() => { - renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) - }) - } finally { - restore() - } + act(() => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) expect(loadHostsMock).toHaveBeenCalledOnce() act(() => renderer?.unmount()) await act(async () => { @@ -401,7 +365,6 @@ describe('useAllHostClients', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -410,7 +373,6 @@ describe('useAllHostClients', () => { expect(connectMock).toHaveBeenCalledOnce() expect(connectMock).toHaveBeenCalledWith(host2, expect.any(Function)) } finally { - restore() act(() => renderer?.unmount()) } }) @@ -433,7 +395,6 @@ describe('useAllHostClients', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -446,7 +407,6 @@ describe('useAllHostClients', () => { 'host-997' ]) } finally { - restore() act(() => renderer?.unmount()) } }) @@ -477,7 +437,6 @@ describe('useAllHostClients', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create( @@ -504,7 +463,6 @@ describe('useAllHostClients', () => { expect(clients.get('host-c')?.closeMock).toHaveBeenCalledOnce() expect(clients.get('host-d')?.closeMock).not.toHaveBeenCalled() } finally { - restore() act(() => renderer?.unmount()) } }) @@ -520,7 +478,6 @@ describe('useAllHostClients', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -528,7 +485,6 @@ describe('useAllHostClients', () => { }) expect(connectMock).toHaveBeenCalledTimes(2) } finally { - restore() act(() => renderer?.unmount()) } }) @@ -545,7 +501,6 @@ describe('useAllHostClients', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) @@ -559,7 +514,6 @@ describe('useAllHostClients', () => { }) expect(connectMock).toHaveBeenCalledOnce() } finally { - restore() act(() => renderer?.unmount()) } }) diff --git a/mobile/src/transport/host-status-gates.test.ts b/mobile/src/transport/host-status-gates.test.ts index 483cfcca50f..f0454243703 100644 --- a/mobile/src/transport/host-status-gates.test.ts +++ b/mobile/src/transport/host-status-gates.test.ts @@ -4,17 +4,6 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from './rpc-client' import { useHostStatusGates, type HostStatusGates } from './host-status-gates' -function suppressReactTestRendererDeprecationWarning(): () => void { - const originalConsoleError = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => spy.mockRestore() -} - describe('useHostStatusGates', () => { it('clears every prior-host gate and ignores its late response while the client is replaced', async () => { let resolveOldStatus: ((response: unknown) => void) | null = null @@ -43,7 +32,6 @@ describe('useHostStatusGates', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(Probe, { hostId: 'host-1', client: oldClient })) @@ -80,7 +68,6 @@ describe('useHostStatusGates', () => { expect(oldSendRequest).toHaveBeenCalledOnce() expect(newSendRequest).toHaveBeenCalledOnce() } finally { - restore() renderer?.unmount() } }) @@ -102,7 +89,6 @@ describe('useHostStatusGates', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(Probe, { hostId: 'host-1' })) @@ -115,7 +101,6 @@ describe('useHostStatusGates', () => { expect(sendRequest).toHaveBeenCalledOnce() } finally { - restore() renderer?.unmount() } }) @@ -141,7 +126,6 @@ describe('useHostStatusGates', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(Probe, { connState: 'connected' })) @@ -181,7 +165,6 @@ describe('useHostStatusGates', () => { statusPending: false }) } finally { - restore() renderer?.unmount() } }) @@ -204,7 +187,6 @@ describe('useHostStatusGates', () => { return null } - const restore = suppressReactTestRendererDeprecationWarning() try { await act(async () => { renderer = create(createElement(Probe, { client: firstClient })) @@ -217,7 +199,6 @@ describe('useHostStatusGates', () => { }) expect(gates).toMatchObject({ hostCapabilities: [], statusPending: true }) } finally { - restore() renderer?.unmount() } }) diff --git a/mobile/src/transport/settings-host-client-lifecycle.test.ts b/mobile/src/transport/settings-host-client-lifecycle.test.ts index 7a23c86ac71..2762e7e85a5 100644 --- a/mobile/src/transport/settings-host-client-lifecycle.test.ts +++ b/mobile/src/transport/settings-host-client-lifecycle.test.ts @@ -151,29 +151,13 @@ function TestApp({ ) } -function suppressRendererWarning(): () => void { - const originalConsoleError = console.error - const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - originalConsoleError(...args) - }) - return () => spy.mockRestore() -} - async function renderScreen(screen: Screen, detailHostId?: string): Promise { let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(createElement(TestApp, { screen, detailHostId })) - await Promise.resolve() - await Promise.resolve() - }) - } finally { - restore() - } + await act(async () => { + renderer = create(createElement(TestApp, { screen, detailHostId })) + await Promise.resolve() + await Promise.resolve() + }) if (!renderer) { throw new Error('settings lifecycle harness did not render') } @@ -203,7 +187,6 @@ function activeHostIds(): string[] { } beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true context = null routeFocus.effect = null connectMock.mockReset() @@ -235,16 +218,11 @@ describe('settings host client lifecycle', () => { } let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(createElement(NavigationStack, { settingsVisible: false })) - await Promise.resolve() - await Promise.resolve() - }) - } finally { - restore() - } + await act(async () => { + renderer = create(createElement(NavigationStack, { settingsVisible: false })) + await Promise.resolve() + await Promise.resolve() + }) if (!renderer) { throw new Error('navigation stack harness did not render') } @@ -354,20 +332,15 @@ describe('settings host client lifecycle', () => { } let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(HostListApp, { - settingsHostIds: [retainedHostId, sharedHostId, removedHostId] - }) - ) - await Promise.resolve() - await Promise.resolve() - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(HostListApp, { + settingsHostIds: [retainedHostId, sharedHostId, removedHostId] + }) + ) + await Promise.resolve() + await Promise.resolve() + }) if (!renderer) { throw new Error('host-list lifecycle harness did not render') } @@ -451,14 +424,9 @@ describe('settings host client lifecycle', () => { } let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - act(() => { - renderer = create(createElement(RetryApp, { settingsVisible: true })) - }) - } finally { - restore() - } + act(() => { + renderer = create(createElement(RetryApp, { settingsVisible: true })) + }) if (!renderer || !resolveInitial || !resolveRetry) { throw new Error('retry lifecycle harness did not initialize') } @@ -517,14 +485,9 @@ describe('settings host client lifecycle', () => { } let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - act(() => { - renderer = create(createElement(PendingApp, { visible: true })) - }) - } finally { - restore() - } + act(() => { + renderer = create(createElement(PendingApp, { visible: true })) + }) if (!renderer || !resolveFirst || !resolveSecond) { throw new Error('pending settings harness did not initialize') } @@ -580,17 +543,12 @@ describe('settings host client lifecycle', () => { } let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create( - createElement(ReplacementApp, { detailVisible: false, settingsVisible: false }) - ) - await Promise.resolve() - }) - } finally { - restore() - } + await act(async () => { + renderer = create( + createElement(ReplacementApp, { detailVisible: false, settingsVisible: false }) + ) + await Promise.resolve() + }) if (!renderer || !context) { throw new Error('replacement lifecycle harness did not initialize') } @@ -665,14 +623,9 @@ describe('settings host client lifecycle', () => { } let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - act(() => { - renderer = create(createElement(ManualApp, { selected: false })) - }) - } finally { - restore() - } + act(() => { + renderer = create(createElement(ManualApp, { selected: false })) + }) if (!renderer || !context) { throw new Error('manual connection harness did not initialize') } @@ -735,16 +688,11 @@ describe('settings host client lifecycle', () => { } let renderer: ReactTestRenderer | null = null - const restore = suppressRendererWarning() - try { - await act(async () => { - renderer = create(createElement(FocusStack)) - await Promise.resolve() - await Promise.resolve() - }) - } finally { - restore() - } + await act(async () => { + renderer = create(createElement(FocusStack)) + await Promise.resolve() + await Promise.resolve() + }) if (!renderer || !routeFocus.effect) { throw new Error('settings focus harness did not initialize') } diff --git a/mobile/vitest.config.ts b/mobile/vitest.config.ts index e39f90eedf1..981a1b55e74 100644 --- a/mobile/vitest.config.ts +++ b/mobile/vitest.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ oxc: vitestOxcConfig, test: { environment: 'node', + setupFiles: ['./vitest.setup.ts'], + onConsoleLog: (log) => !log.includes('react-test-renderer is deprecated'), // .tsx too: component tests exist (react-test-renderer + mocked react-native) and were // silently never collected, so render-level regressions shipped untested. include: ['src/**/*.test.ts', 'src/**/*.test.tsx'] diff --git a/mobile/vitest.setup.ts b/mobile/vitest.setup.ts new file mode 100644 index 00000000000..740c1eb1bab --- /dev/null +++ b/mobile/vitest.setup.ts @@ -0,0 +1 @@ +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) From 65a5e8470ad89efafe17795137c0f75d02a9d159 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:31:16 -0700 Subject: [PATCH 006/271] refactor: share GitHub link normalization (#13409) --- src/renderer/src/lib/github-links.ts | 57 +--------------------------- 1 file changed, 1 insertion(+), 56 deletions(-) diff --git a/src/renderer/src/lib/github-links.ts b/src/renderer/src/lib/github-links.ts index 34a53c0494e..766d8a0eb28 100644 --- a/src/renderer/src/lib/github-links.ts +++ b/src/renderer/src/lib/github-links.ts @@ -1,56 +1 @@ -// Why: the parsing core moved to shared so main's terminal side-effect -// tracker can emit pr-link facts (terminal-side-effect-authority.md, slice 3). -// Re-exported here so renderer consumers keep their '@/lib' import path. -// normalizeGitHubLinkQuery stays renderer-side: its too-large guard is link- -// picker input policy, not parsing. -import { - type GitHubIssueOrPRLink, - parseGitHubIssueOrPRLink, - parseGitHubIssueOrPRNumber -} from '../../../shared/github-links' - -import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' - -export * from '../../../shared/github-links' - -const HTTP_URL_PREFIX_RE = /^https?:\/\//i - -export type GitHubLinkQuery = { - query: string - directNumber: number | null - directLink?: GitHubIssueOrPRLink - tooLarge?: boolean -} - -/** - * Normalizes link-picker input so both raw issue/PR numbers and full GitHub - * URLs resolve to a usable query + direct-number lookup. - */ -export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery { - if (isWorkItemLinkQueryTooLarge(raw)) { - return { query: '', directNumber: null, tooLarge: true } - } - const trimmed = raw.trim() - if (!trimmed) { - return { query: '', directNumber: null } - } - - const direct = parseGitHubIssueOrPRNumber(trimmed) - if (direct !== null && !HTTP_URL_PREFIX_RE.test(trimmed)) { - return { query: trimmed, directNumber: direct } - } - - const link = parseGitHubIssueOrPRLink(trimmed) - if (!link) { - return { query: trimmed, directNumber: null } - } - - // Why: any GitHub-shaped issue/pull URL is accepted by number regardless of - // slug, since fork checkouts can legitimately target upstream issues whose - // slug differs from the origin remote. - return { - query: trimmed, - directNumber: link.number, - directLink: link - } -} +export * from '../../../shared/new-workspace/github-links' From 1e72534709c5a1f56d8ddca4da1b48cde9e8e535 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:31:36 -0700 Subject: [PATCH 007/271] refactor: deduplicate repository search entries (#13415) --- .../settings/RepositoryPane.test.ts | 7 ++++ .../components/settings/repository-search.ts | 35 ------------------- 2 files changed, 7 insertions(+), 35 deletions(-) diff --git a/src/renderer/src/components/settings/RepositoryPane.test.ts b/src/renderer/src/components/settings/RepositoryPane.test.ts index f7b5d09da09..47c00a0a601 100644 --- a/src/renderer/src/components/settings/RepositoryPane.test.ts +++ b/src/renderer/src/components/settings/RepositoryPane.test.ts @@ -104,6 +104,13 @@ describe('RepositoryPane search entries', () => { expect(matchesSettingsSearch('worktree path', entries)).toBe(true) }) + it('includes each project search section once', () => { + const entries = getRepositoryPaneSearchEntries(repo) + + expect(entries.filter(({ title }) => title === 'Project Icon')).toHaveLength(1) + expect(entries.filter(({ title }) => title === 'Default Worktree Base')).toHaveLength(1) + }) + it('omits project runtime search for remote or unsupported repos', () => { expect(matchesSettingsSearch('project runtime', getRepositoryPaneSearchEntries(repo))).toBe( false diff --git a/src/renderer/src/components/settings/repository-search.ts b/src/renderer/src/components/settings/repository-search.ts index 1b7a39e7f75..ea878ee2b8e 100644 --- a/src/renderer/src/components/settings/repository-search.ts +++ b/src/renderer/src/components/settings/repository-search.ts @@ -185,41 +185,6 @@ export function getRepositoryPaneSearchEntries( ) ] }, - { - title: translate('auto.components.settings.repository.search.b24f00294a', 'Project Icon'), - description: translate( - 'auto.components.settings.repository.search.a1f3a2bd47', - 'Project icon and color used in the sidebar and tabs.' - ), - keywords: [ - repo.displayName, - ...translateSearchKeyword( - 'auto.components.settings.repository.search.6438a94c63', - 'project icon' - ), - ...translateSearchKeyword( - 'auto.components.settings.repository.search.b2546efab5', - 'repository icon' - ), - ...translateSearchKeyword('auto.components.settings.repository.search.8d045419b1', 'color'), - ...translateSearchKeyword('auto.components.settings.repository.search.6d8de2f090', 'hex'), - ...translateSearchKeyword('auto.components.settings.repository.search.c1075178cf', 'badge'), - ...translateSearchKeyword( - 'auto.components.settings.repository.search.cb4b4de666', - 'avatar' - ), - ...translateSearchKeyword( - 'auto.components.settings.repository.search.9dc60d7f6d', - 'github' - ), - ...translateSearchKeyword('auto.components.settings.repository.search.1e73e840ff', 'emoji'), - ...translateSearchKeyword( - 'auto.components.settings.repository.search.27733eb6c1', - 'favicon' - ) - ] - }, - ...(isFolder ? [] : getRepositoryGitWorktreeSearchEntries(repo)), ...(isFolder ? [] : [...getRepositoryGitAuthorSearchEntries(repo), ...getRepositoryGitHooksSearchEntries(repo)]) From cb6069ceea4692ee31c6065e630fd24f9895b8b6 Mon Sep 17 00:00:00 2001 From: Seiyoung Park Date: Mon, 10 Aug 2026 10:34:05 +0900 Subject: [PATCH 008/271] fix(ui): align toggle switch handle symmetrically in on state (#9714) * fix(ui): align toggle switch handle symmetrically in on state The switch handle (translate-x-4 / 16px) left a 6px gap on the right in the on state while the off state had only a 2px gap on the left. Accounting for border-box sizing (1px border each side reduces content width to 34px), the correct on-position is translate-x-4.5 (18px) so both sides have a 2px inset. * docs: add JSDoc to exported functions in changed files for docstring coverage * fix(ui): align toggle switch handle symmetrically in on state The `h-5 w-9` switch track is `border-box` with a 1px border, leaving 34px of content for the 14px `size-3.5` handle. `translate-x-0.5` insets the off state by 2px, so the on state needs 34 - 14 - 2 = 18px. It used `translate-x-4` (16px), leaving 4px on the right against 2px on the left. Adds a boundary test so the offset cannot drift again across the 26 hand-rolled switch call sites, and drops the docstrings the original patch added to satisfy a coverage bot (the surrounding files carry none). Original patch by @sei0. Co-authored-by: Orca * test(ui): widen the switch-handle alignment guard to every h-5 w-9 track Scan the renderer with the readdirSync walk and TypeScript AST already used by no-top-level-translate.test.ts instead of shelling out to `git grep`, and find each knob inside its own track rather than by scanning source text forward. Why: gating on `role="switch"` silently skipped ClaudeUsageLoadingState.tsx (a loading skeleton, so a div with no role) and HiddenExperimentalGroup.tsx, so regressing the loading-state handle back to translate-x-4 left the guard green. The guard now also fails when the premise behind 34 - 14 - 2 = 18px stops holding: a track that loses its 1px border or gains padding, a conditional whose two branches no longer pair off/on, and a track whose knob moved into a child component where the scan would otherwise just stop seeing it. --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../components/NewWorkspaceComposerCard.tsx | 2 +- .../ContextualTourControl.tsx | 2 +- .../feature-wall/AiCommitPrSettingsSwitch.tsx | 2 +- .../components/feature-wall/KeepAwakeCard.tsx | 2 +- .../src/components/settings/AccountsPane.tsx | 2 +- .../components/settings/AgentAwakeSetting.tsx | 2 +- .../AutoRenameBranchFromWorkSetting.tsx | 2 +- .../settings/BrowserLinkRoutingSetting.tsx | 2 +- .../settings/BrowserUseEnableSwitch.tsx | 2 +- .../src/components/settings/CliSection.tsx | 2 +- .../settings/CommitMessageAiPane.tsx | 2 +- .../components/settings/ExperimentalPane.tsx | 6 +- .../src/components/settings/GitPane.tsx | 4 +- .../src/components/settings/InputPane.tsx | 2 +- .../settings/NotificationSettingToggle.tsx | 2 +- .../src/components/settings/PrivacyPane.tsx | 2 +- .../settings/SettingsFormControls.tsx | 2 +- .../settings/TerminalWindowSection.tsx | 4 +- .../VoiceDictationSettingsSection.tsx | 2 +- .../settings/WslCliRegistration.tsx | 2 +- .../components/sidebar/HostRemoveDialog.tsx | 2 +- .../stats/ClaudeUsageLoadingState.tsx | 2 +- .../src/components/stats/ClaudeUsagePane.tsx | 2 +- .../src/components/stats/CodexUsagePane.tsx | 2 +- .../components/stats/OpenCodeUsagePane.tsx | 2 +- .../TerminalQuickCommandAppendEnterSwitch.tsx | 2 +- .../toggle-switch-handle-alignment.test.ts | 231 ++++++++++++++++++ 27 files changed, 261 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/components/toggle-switch-handle-alignment.test.ts diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 478494916dd..2447e45f21b 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -1219,7 +1219,7 @@ export default function NewWorkspaceComposerCard({ diff --git a/src/renderer/src/components/contextual-tours/ContextualTourControl.tsx b/src/renderer/src/components/contextual-tours/ContextualTourControl.tsx index 7898b579521..0a2186c5077 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourControl.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourControl.tsx @@ -66,7 +66,7 @@ function AutoRenameBranchFromWorkControl(): JSX.Element { diff --git a/src/renderer/src/components/feature-wall/AiCommitPrSettingsSwitch.tsx b/src/renderer/src/components/feature-wall/AiCommitPrSettingsSwitch.tsx index 7655cd88ec5..751722d95ff 100644 --- a/src/renderer/src/components/feature-wall/AiCommitPrSettingsSwitch.tsx +++ b/src/renderer/src/components/feature-wall/AiCommitPrSettingsSwitch.tsx @@ -27,7 +27,7 @@ export function AiCommitPrSettingsSwitch({ diff --git a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx index 6340d2f1b71..2a7b57adc67 100644 --- a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx +++ b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx @@ -38,7 +38,7 @@ export function KeepAwakeCard(props: { diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index d9db52dd9c6..0a3dd5ae786 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -1570,7 +1570,7 @@ export function AccountsPane({ > diff --git a/src/renderer/src/components/settings/AgentAwakeSetting.tsx b/src/renderer/src/components/settings/AgentAwakeSetting.tsx index 66e56c32aa2..28cd7cc0114 100644 --- a/src/renderer/src/components/settings/AgentAwakeSetting.tsx +++ b/src/renderer/src/components/settings/AgentAwakeSetting.tsx @@ -50,7 +50,7 @@ export function AgentAwakeSetting({ > diff --git a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx index c4d520cbdb7..2cbdf227e0d 100644 --- a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx +++ b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx @@ -186,7 +186,7 @@ export function AutoRenameBranchFromWorkSetting({ > diff --git a/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx b/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx index 6b0885a86ef..b26c1e2fd78 100644 --- a/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx +++ b/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx @@ -54,7 +54,7 @@ export function BrowserLinkRoutingSetting({ > diff --git a/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx b/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx index cbb4c3361b4..28a071b1583 100644 --- a/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx +++ b/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx @@ -21,7 +21,7 @@ export function BrowserUseEnableSwitch({ > diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 41984fe2038..86a5c89f346 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -299,7 +299,7 @@ export function CliSection({ > diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index e24fc97ee40..13f1b10cf52 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -209,7 +209,7 @@ export function CommitMessageAiPane({ > diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 62d8420be13..9760591d477 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -99,7 +99,7 @@ export function ExperimentalPane({ > @@ -144,7 +144,7 @@ export function ExperimentalPane({ > @@ -203,7 +203,7 @@ export function ExperimentalPane({ > diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx index da1a34c3fe8..490cef11e36 100644 --- a/src/renderer/src/components/settings/GitPane.tsx +++ b/src/renderer/src/components/settings/GitPane.tsx @@ -291,7 +291,7 @@ export function GitPane({ > @@ -385,7 +385,7 @@ export function GitPane({ > diff --git a/src/renderer/src/components/settings/InputPane.tsx b/src/renderer/src/components/settings/InputPane.tsx index 55094ec4a7a..125e90cc003 100644 --- a/src/renderer/src/components/settings/InputPane.tsx +++ b/src/renderer/src/components/settings/InputPane.tsx @@ -70,7 +70,7 @@ export function InputPane({ settings, updateSettings }: InputPaneProps): React.J > diff --git a/src/renderer/src/components/settings/NotificationSettingToggle.tsx b/src/renderer/src/components/settings/NotificationSettingToggle.tsx index 4de95f53b3e..93502392487 100644 --- a/src/renderer/src/components/settings/NotificationSettingToggle.tsx +++ b/src/renderer/src/components/settings/NotificationSettingToggle.tsx @@ -39,7 +39,7 @@ export function NotificationSettingToggle({ > diff --git a/src/renderer/src/components/settings/PrivacyPane.tsx b/src/renderer/src/components/settings/PrivacyPane.tsx index f2334e8676e..8a096f8c2c1 100644 --- a/src/renderer/src/components/settings/PrivacyPane.tsx +++ b/src/renderer/src/components/settings/PrivacyPane.tsx @@ -127,7 +127,7 @@ export function PrivacyPane({ settings }: PrivacyPaneProps): React.JSX.Element { > diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index a2d57a6ac24..27bcf7acc12 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -52,7 +52,7 @@ export function SettingsSwitch({ diff --git a/src/renderer/src/components/settings/TerminalWindowSection.tsx b/src/renderer/src/components/settings/TerminalWindowSection.tsx index 0cd00060223..3ca33d25c39 100644 --- a/src/renderer/src/components/settings/TerminalWindowSection.tsx +++ b/src/renderer/src/components/settings/TerminalWindowSection.tsx @@ -133,7 +133,7 @@ export function TerminalWindowSection({ > @@ -270,7 +270,7 @@ export function TerminalWindowSection({ diff --git a/src/renderer/src/components/settings/VoiceDictationSettingsSection.tsx b/src/renderer/src/components/settings/VoiceDictationSettingsSection.tsx index f148e631dc4..3747781b993 100644 --- a/src/renderer/src/components/settings/VoiceDictationSettingsSection.tsx +++ b/src/renderer/src/components/settings/VoiceDictationSettingsSection.tsx @@ -51,7 +51,7 @@ export function VoiceDictationSettingsSection({ > diff --git a/src/renderer/src/components/settings/WslCliRegistration.tsx b/src/renderer/src/components/settings/WslCliRegistration.tsx index 7d01d96d58b..586d306846e 100644 --- a/src/renderer/src/components/settings/WslCliRegistration.tsx +++ b/src/renderer/src/components/settings/WslCliRegistration.tsx @@ -198,7 +198,7 @@ export function WslCliRegistration({ > diff --git a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx index 97135f97eeb..d99f5e860e9 100644 --- a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx +++ b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx @@ -251,7 +251,7 @@ export function HostRemoveDialog({ diff --git a/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx b/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx index e459ab9c6ba..59c378cc8c3 100644 --- a/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx +++ b/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx @@ -21,7 +21,7 @@ export function ClaudeUsageLoadingState({
- +
diff --git a/src/renderer/src/components/stats/ClaudeUsagePane.tsx b/src/renderer/src/components/stats/ClaudeUsagePane.tsx index 32536628f8d..dfd83b6663c 100644 --- a/src/renderer/src/components/stats/ClaudeUsagePane.tsx +++ b/src/renderer/src/components/stats/ClaudeUsagePane.tsx @@ -233,7 +233,7 @@ export function ClaudeUsagePane(): React.JSX.Element { onClick={() => handleSetEnabled(false)} className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors" > - +
diff --git a/src/renderer/src/components/stats/CodexUsagePane.tsx b/src/renderer/src/components/stats/CodexUsagePane.tsx index 2ebd6913d44..4ae5074de51 100644 --- a/src/renderer/src/components/stats/CodexUsagePane.tsx +++ b/src/renderer/src/components/stats/CodexUsagePane.tsx @@ -235,7 +235,7 @@ export function CodexUsagePane(): React.JSX.Element { onClick={() => handleSetEnabled(false)} className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors" > - +
diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx index c09f2a5a179..b70b065934e 100644 --- a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -246,7 +246,7 @@ export function OpenCodeUsagePane(): React.JSX.Element { onClick={() => handleSetEnabled(false)} className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors" > - + diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx index ad45db4b08d..0ddd6222465 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx @@ -39,7 +39,7 @@ export function TerminalQuickCommandAppendEnterSwitch({ > diff --git a/src/renderer/src/components/toggle-switch-handle-alignment.test.ts b/src/renderer/src/components/toggle-switch-handle-alignment.test.ts new file mode 100644 index 00000000000..b0a5574dd27 --- /dev/null +++ b/src/renderer/src/components/toggle-switch-handle-alignment.test.ts @@ -0,0 +1,231 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { relative, resolve } from 'node:path' +// TypeScript 7 is a native CLI; AST tests still need the legacy JavaScript API. +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +// Why: the h-5 w-9 switch is hand-rolled at every call site instead of living in a +// primitive, so the handle offset drifts. The 1px-bordered track leaves a 34px content +// box for the 14px (`size-3.5`) handle, and `translate-x-0.5` insets the off state by +// 2px, so a symmetric on state is 34 - 14 - 2 = 18px (`translate-x-4.5`). + +const RENDERER_ROOT = resolve('src/renderer/src') + +const OFF_STATE = 'translate-x-0.5' +const ON_STATE = 'translate-x-4.5' + +/** The track geometry the arithmetic above is derived from; other switch sizes are not ours to check. */ +const TRACK = ['h-5', 'w-9'] + +/** The handle's 14px size, in both the shorthand and long-hand spellings in use. */ +const HANDLE_SIZE = [['size-3.5'], ['h-3.5', 'w-3.5']] + +/** Every current handle is the round knob; this is what separates it from a size-3.5 icon in the same track. */ +const HANDLE_SHAPE = 'rounded-full' + +/** Any horizontal translate — negative, arbitrary, variant-prefixed or named — so no respelling reads as "no offset". */ +const OFFSET = /(?:^|:)-?translate-x-\S+$/ + +/** Anything that moves the track off the 34px content box the 18px on-state is derived from. */ +const BORDER_WIDTH = /^border(?:-[xytrbles])?-(?:\d+|\[)/ +const TRACK_PADDING = /^p[xytrbles]?-(?:\d|\[)/ + +function collectSourceFiles(dir: string, files: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const filePath = resolve(dir, name) + if (statSync(filePath).isDirectory()) { + collectSourceFiles(filePath, files) + } else if (name.endsWith('.tsx') && !name.endsWith('.test.tsx')) { + files.push(filePath) + } + } + return files +} + +/** Class tokens anywhere under a node, flattening the ternaries, template holes and `cn()` args a className is built from. */ +function tokensIn(node: ts.Node): string[] { + const tokens: string[] = [] + const read = (child: ts.Node): void => { + if (ts.isStringLiteral(child) || ts.isTemplateLiteralToken(child)) { + tokens.push(...child.text.split(/\s+/).filter(Boolean)) + } + ts.forEachChild(child, read) + } + read(node) + return tokens +} + +/** Reading the className attribute's own subtree keeps a child element's classes out of its parent's token set. */ +function classNameOf(element: ts.JsxOpeningLikeElement): ts.Node | undefined { + const className = element.attributes.properties.find( + (property): property is ts.JsxAttribute => + ts.isJsxAttribute(property) && property.name.getText() === 'className' + ) + return className?.initializer +} + +function hasAll(tokens: string[], required: string[]): boolean { + return required.every((token) => tokens.includes(token)) +} + +/** + * The offsets each side of a conditional className contributes. A handle that toggles is + * two-state by construction, so this tells a deliberately single-state knob apart from one + * that lost a branch — which a flat token list cannot. + */ +function offsetBranches(node: ts.Node): [string[], string[]][] { + const branches: [string[], string[]][] = [] + const read = (child: ts.Node): void => { + if (ts.isConditionalExpression(child)) { + const whenTrue = tokensIn(child.whenTrue).filter((token) => OFFSET.test(token)) + const whenFalse = tokensIn(child.whenFalse).filter((token) => OFFSET.test(token)) + if (whenTrue.length > 0 || whenFalse.length > 0) { + branches.push([whenTrue, whenFalse]) + return + } + } + ts.forEachChild(child, read) + } + read(node) + return branches +} + +function trackDrift(tokens: string[]): string | undefined { + if (!tokens.includes('border')) { + return 'no 1px border' + } + if (tokens.includes('box-content')) { + return 'box-content' + } + return tokens.find((token) => BORDER_WIDTH.test(token) || TRACK_PADDING.test(token)) +} + +type Handle = { at: string; offsets: string[] } +type Toggle = { at: string; branches: [string[], string[]] } +type Scan = { handles: Handle[]; toggles: Toggle[]; drifted: string[]; knobless: string[] } + +/** + * Handles are found *inside* a track element rather than by scanning source text forward, so + * neither a sibling element nor a same-file icon can be mistaken for the knob. + */ +function collectFile(filePath: string, source: string, scan: Scan): void { + const sourceFile = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ) + + const at = (node: ts.Node): string => { + const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) + return `${relative(RENDERER_ROOT, filePath)}:${line + 1}` + } + + const readHandles = (node: ts.Node): void => { + if (ts.isJsxOpeningLikeElement(node)) { + const className = classNameOf(node) + const tokens = className ? tokensIn(className) : [] + if ( + className && + HANDLE_SIZE.some((size) => hasAll(tokens, size)) && + tokens.includes(HANDLE_SHAPE) + ) { + scan.handles.push({ at: at(node), offsets: tokens.filter((token) => OFFSET.test(token)) }) + for (const branches of offsetBranches(className)) { + scan.toggles.push({ at: at(node), branches }) + } + } + } + ts.forEachChild(node, readHandles) + } + + const visit = (node: ts.Node): void => { + if (ts.isJsxOpeningLikeElement(node)) { + const className = classNameOf(node) + const tokens = className ? tokensIn(className) : [] + if (hasAll(tokens, TRACK)) { + const drift = trackDrift(tokens) + if (drift) { + scan.drifted.push(`${at(node)}: ${drift}`) + } + // The knob lives in the track's subtree; a self-closing track has no subtree to walk. + const found = scan.handles.length + if (ts.isJsxOpeningElement(node)) { + readHandles(node.parent) + } + // A track whose knob moved into a child component would otherwise leave the scan silently. + if (scan.handles.length === found) { + scan.knobless.push(at(node)) + } + return + } + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) +} + +let cached: Scan | undefined + +/** Parsing every gated file is the expensive part, so all three assertions share one scan. */ +function scanRenderer(): Scan { + if (!cached) { + const scan: Scan = { handles: [], toggles: [], drifted: [], knobless: [] } + + for (const filePath of collectSourceFiles(RENDERER_ROOT)) { + const source = readFileSync(filePath, 'utf8') + if (TRACK.every((token) => source.includes(token))) { + collectFile(filePath, source, scan) + } + } + + // A track nested inside another h-5 w-9 element would otherwise be collected twice. + const seen = new Set() + scan.handles = scan.handles.filter((handle) => !seen.has(handle.at) && seen.add(handle.at)) + cached = scan + } + + // A scan that stops finding switches has lost its bounds, not proven the tree clean. The + // floor only has to catch a collapse — `knobless` and `drifted` catch the structural cases — + // so it sits well under the ~34 handles in the tree rather than pinning the current count. + expect(cached.handles.length, 'the switch scan lost its bounds').toBeGreaterThan(25) + + return cached +} + +describe('toggle switch handle alignment', () => { + it('insets the handle by 2px at both ends of the track', () => { + const offenders = scanRenderer().handles.flatMap((handle) => + handle.offsets.length === 0 + ? [`${handle.at}: no translate-x offset`] + : handle.offsets + .filter((offset) => offset !== OFF_STATE && offset !== ON_STATE) + .map((offset) => `${handle.at}: ${offset}`) + ) + + expect(offenders).toEqual([]) + }) + + it('gives every toggling handle both ends of the pair', () => { + const pairs = (on: string[], off: string[]): boolean => + on.length === 1 && on[0] === ON_STATE && off.length === 1 && off[0] === OFF_STATE + + const offenders = scanRenderer() + .toggles.filter(({ branches: [a, b] }) => !pairs(a, b) && !pairs(b, a)) + .map( + ({ at, branches }) => `${at}: ${branches.map((side) => side.join(' ') || '—').join(' / ')}` + ) + + expect(offenders).toEqual([]) + }) + + it('keeps every track on the geometry the 18px on-state is derived from', () => { + expect(scanRenderer().drifted).toEqual([]) + }) + + it('keeps every track a knob the scan can see', () => { + expect(scanRenderer().knobless).toEqual([]) + }) +}) From b075a95b06307abac4bf42d175cf2c0b9439737f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:40:01 -0700 Subject: [PATCH 009/271] Strip liveness gate from AI Vault session delete (#13279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Strip liveness gate from AI Vault session delete Delete now requires only path validation + user confirmation — no process roster, no liveness check, no quiescence, no ownership ledger. Co-authored-by: Orca * Remove obsolete AI Vault liveness delete reliability gate Session delete no longer checks process liveness, so drop the manifest entry that still referenced the deleted test files. * minor fix --------- Co-authored-by: Orca --- config/reliability-gates.jsonc | 99 +------ .../session-delete-liveness.repro.test.ts | 179 ------------ src/main/ai-vault/session-delete.test.ts | 10 +- src/main/ai-vault/session-delete.ts | 31 +-- src/main/ai-vault/session-liveness.test.ts | 263 ------------------ src/main/ai-vault/session-liveness.ts | 187 ------------- src/main/ipc/ai-vault-delete.ts | 25 +- src/main/ipc/ai-vault.test.ts | 6 +- src/main/ipc/ai-vault.ts | 6 +- src/main/ipc/register-core-handlers.ts | 3 +- src/main/runtime/orca-runtime.ts | 75 +---- .../right-sidebar/AiVaultSessionRow.tsx | 2 +- .../ai-vault-session-deletability.test.ts | 23 -- .../ai-vault-session-deletability.ts | 18 +- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/shared/ai-vault-session-deletion.ts | 7 +- .../terminal-macos-system-key-remap.spec.ts | 7 +- 21 files changed, 32 insertions(+), 924 deletions(-) delete mode 100644 src/main/ai-vault/session-delete-liveness.repro.test.ts delete mode 100644 src/main/ai-vault/session-liveness.test.ts delete mode 100644 src/main/ai-vault/session-liveness.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3e4a32a0cfb..3993a178f38 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-07", + "updatedAt": "2026-08-09", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -10,103 +10,6 @@ } }, "gates": [ - { - "id": "ai-vault.live-session-delete-authority", - "title": "AI Vault deletion fails closed against owning-runtime session liveness", - "maturity": "experimental", - "protection": "partial", - "owner": "ai-vault-runtime", - "layer": "main-process-filesystem-mutation", - "surfaces": [ - "AI Vault transcript deletion", - "local and WSL agent sessions", - "paired-client-owned host sessions" - ], - "platforms": ["macos", "linux", "windows"], - "providers": ["local", "wsl", "ssh", "paired-runtime"], - "coveredPlatforms": ["macos", "linux", "windows"], - "coveredProviders": ["local", "wsl", "ssh", "paired-runtime"], - "coverageNotes": "Platform-independent contracts cover local and WSL authority, paired-owned and unmanaged live sessions absent from renderer or managed-PTY state, identity retention across UI dismissal, inventory and foreground-inspection ordering, SSH exclusion, unavailable and ambiguous inventory, bounded inspection concurrency, oversized inventories, returned rejection, and real temporary-file survival. Live headed/headless paired-runtime, physical Windows/WSL, and Docker SSH journeys remain uncollected.", - "motivatingLinks": ["https://github.com/stablyai/orca/releases/tag/v1.4.177-rc.0"], - "invariant": "Main may delete a local AI transcript only after the owning runtime freshly proves that no live local or WSL provider-session identity exists, including exact hook identities absent from managed PTY inventory; live and unknown liveness preserve the transcript regardless of renderer freshness.", - "oracle": "Open confirmation against a stale empty renderer snapshot, transition the authoritative session to live before main authorization and during a controlled foreground-inspection barrier, and separately model paired and unmanaged local owners absent from renderer or managed-PTY state. Dismiss a live row and require its identity-only authority to remain. Require session-live or session-liveness-unknown rejection and the real temporary transcript to survive. Make process inventory unavailable or ambiguous and require unknown with the file preserved; prove known other sessions permit deletion and inspection concurrency stays at or below eight.", - "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/ai-vault/session-delete-liveness.repro.test.ts src/main/ai-vault/session-liveness.test.ts src/main/agent-hooks/server-ai-vault-liveness.test.ts --reporter=dot" - ], - "testFiles": [ - "src/main/ai-vault/session-delete-liveness.repro.test.ts", - "src/main/ai-vault/session-liveness.test.ts", - "src/main/agent-hooks/server-ai-vault-liveness.test.ts" - ], - "assertionRefs": [ - { - "file": "src/main/ai-vault/session-delete-liveness.repro.test.ts", - "assertions": [ - "confirmation TOCTOU returns session-live and preserves the real transcript", - "paired ownership missing from the renderer snapshot returns session-live and preserves the real transcript", - "exact live local hook identity missing from managed PTY inventory returns unknown and preserves the real transcript", - "dismissed live status retained as identity-only authority returns unknown and preserves the real transcript", - "unknown authoritative liveness fails closed and preserves the real transcript" - ] - }, - { - "file": "src/main/ai-vault/session-liveness.test.ts", - "assertions": [ - "local, WSL, paired-runtime, and SSH host partitions resolve without local fallback", - "renderer session identity must match the identity parsed from the validated transcript", - "hook identity is captured after the controlled process-inventory barrier", - "hook identity is captured after the controlled foreground-inspection barrier", - "missing identity, unavailable inventory, unattributed ownership, and oversized inventory remain unknown", - "foreground inspection concurrency never exceeds eight and stops scheduling at the shared deadline" - ] - }, - { - "file": "src/main/agent-hooks/server-ai-vault-liveness.test.ts", - "assertions": [ - "user dismissal retains provider identity without retaining a visible turn-status row", - "restart hydration preserves dismissed identity as unconfirmed liveness-only authority" - ] - } - ], - "evidenceRuns": [ - { - "date": "2026-08-07", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ai-vault/session-delete-liveness.repro.test.ts src/main/ai-vault/session-liveness.test.ts src/main/agent-hooks/server-ai-vault-liveness.test.ts --reporter=dot", - "result": "passed", - "durationSeconds": 1, - "summary": "Twenty deterministic deletion, identity-binding, inventory/inspection-ordering, dismissal/restart-retention, unmanaged-ownership, unknown-state, filesystem-survival, host-partition, deadline, and bounded-concurrency assertions passed." - } - ], - "runtimeBudget": { - "p95Seconds": 2, - "scope": "focused main-process liveness and temporary-filesystem contracts" - }, - "flakeHistory": { - "status": "unknown", - "evidence": "One local deterministic run is recorded; CI and soak history have not started." - }, - "redGreenEvidence": { - "status": "complete", - "evidence": "The byte-identical two-scenario oracle failed on v1.4.177-rc.0 (9e948fbdf4) and latest main (7a867f12aa): both returned deleted and both real transcripts were removed. On PR commit 8e2fcfd681, an exact live local hook identity absent from empty managed-PTY inventory returned deleted and removed the real transcript. Independent review then proved a session-other to session-live transition during foreground inspection returned not-live, and live-row dismissal erased the only external identity. The corrected candidate passed all 20 assertions, including restart hydration of dismissed identity; temporarily disabling only the original liveness decision made all three original deletion-safety tests red again." - }, - "performanceBudget": { - "required": true, - "evidence": "Deletion parses only the validated target through the incremental parse cache, performs one local-provider process listing, makes no SSH or paired-runtime request, caps inventory at 512 PTYs, bounds foreground inspections to eight concurrent calls under one shared three-second fail-closed deadline, and adds no polling or background work." - }, - "promotionCriteria": [ - "Collect CI soak history for the deterministic gate.", - "Collect headed and headless paired-runtime deletion-refusal evidence.", - "Collect physical Windows/WSL and Docker SSH host-partition evidence." - ], - "knownGaps": [ - "No live headed/headless paired-runtime journey was run.", - "No physical Windows/WSL or Docker SSH journey was run.", - "The authorization check cannot make an external provider process start atomic with filesystem trashing; it closes the reported renderer-confirmation window at the main mutation boundary." - ], - "demotionRule": "Keep experimental or demote if live or unknown ownership can reach trashing, remote ownership falls back locally, process inspection becomes unbounded, or the deterministic filesystem oracle flakes without a product or harness defect." - }, { "id": "editor.restored-sibling-owner-reparent", "title": "Restored sibling tabs migrate filesystem authority before becoming editable", diff --git a/src/main/ai-vault/session-delete-liveness.repro.test.ts b/src/main/ai-vault/session-delete-liveness.repro.test.ts deleted file mode 100644 index 6e4928a84ea..00000000000 --- a/src/main/ai-vault/session-delete-liveness.repro.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { existsSync } from 'node:fs' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -import type { AiVaultDeleteSessionResult } from '../../shared/ai-vault-session-deletion' -import type { ValidateAiVaultSessionDeleteTargetArgs } from './session-delete-target' - -const { trashItemMock } = vi.hoisted(() => ({ trashItemMock: vi.fn() })) - -vi.mock('electron', () => ({ shell: { trashItem: trashItemMock } })) -vi.mock('../wsl-unc-delete', () => ({ tryDeleteWslUncPath: vi.fn().mockResolvedValue(false) })) - -import { deleteAiVaultSessionFile } from './session-delete' -import { resolveAiVaultSessionLiveness } from './session-liveness' - -type AiVaultSessionLiveness = 'live' | 'not-live' | 'unknown' - -type DeleteWithLiveness = ( - args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId: string }, - deps: { getSessionLiveness: () => Promise } -) => Promise - -const deleteWithLiveness = deleteAiVaultSessionFile as unknown as DeleteWithLiveness -const fixtureRoots: string[] = [] - -async function createTranscript(): Promise<{ filePath: string; root: string }> { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-live-delete-repro-')) - fixtureRoots.push(root) - const filePath = join(root, 'session-live.json') - await writeFile(filePath, '{"sessionId":"session-live"}\n') - return { filePath, root } -} - -async function attemptDelete( - getSessionLiveness: () => Promise -): Promise<{ filePath: string; result: AiVaultDeleteSessionResult }> { - const { filePath, root } = await createTranscript() - const result = await deleteWithLiveness( - { - agent: 'gemini', - sessionId: 'session-live', - filePath, - executionHostId: 'local', - rootOptions: { geminiSessionsDir: root } - }, - { getSessionLiveness } - ) - return { filePath, result } -} - -describe('live AI Vault session delete safety invariant', () => { - afterEach(async () => { - trashItemMock.mockReset() - await Promise.all( - fixtureRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) - ) - }) - - it('survives when the session becomes live after confirmation but before main authorization', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - const rendererPaneSnapshot = new Set() - expect(rendererPaneSnapshot.has('session-live')).toBe(false) - - let authoritativeLiveness: AiVaultSessionLiveness = 'not-live' - const confirmationOpened = true - authoritativeLiveness = 'live' - expect(confirmationOpened).toBe(true) - - const { filePath, result } = await attemptDelete(async () => authoritativeLiveness) - - expect.soft(result).toEqual({ outcome: 'rejected', agent: 'gemini', reason: 'session-live' }) - expect.soft(existsSync(filePath), 'live transcript must survive on disk').toBe(true) - }) - - it('survives when a paired owner is absent from the renderer pane snapshot', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - const rendererPaneSnapshot = new Set() - const authoritativeOwner = { - runtimeId: 'host-runtime', - connectionId: 'paired-client', - generation: 'generation-1', - sessionId: 'session-live', - liveness: 'live' as const - } - expect(rendererPaneSnapshot.has(authoritativeOwner.sessionId)).toBe(false) - - const { filePath, result } = await attemptDelete(async () => authoritativeOwner.liveness) - - expect.soft(result).toEqual({ outcome: 'rejected', agent: 'gemini', reason: 'session-live' }) - expect.soft(existsSync(filePath), 'externally owned transcript must survive on disk').toBe(true) - }) - - it('survives an exact live local hook identity absent from managed PTY inventory', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - const inspectForegroundProcess = vi.fn() - const externalLiveStatus: AgentStatusIpcPayload = { - paneKey: 'tab:external-live', - terminalHandle: 'term_external-live', - connectionId: null, - receivedAt: 1, - stateStartedAt: 1, - state: 'working', - prompt: 'test', - agentType: 'gemini', - providerSession: { key: 'session_id', id: 'session-live' } - } - - const { filePath, result } = await attemptDelete(() => - resolveAiVaultSessionLiveness( - { agent: 'gemini', sessionId: 'session-live' }, - { - listProcesses: async () => [], - getStatusSnapshot: () => [externalLiveStatus], - inspectForegroundProcess, - getStatusPtyId: () => 'external-live', - getAgentHint: () => null - } - ) - ) - - expect.soft(result).toEqual({ - outcome: 'rejected', - agent: 'gemini', - reason: 'session-liveness-unknown' - }) - expect.soft(existsSync(filePath), 'external live transcript must survive on disk').toBe(true) - expect(inspectForegroundProcess).not.toHaveBeenCalled() - }) - - it('survives a dismissed live row retained as liveness-only identity', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - const retainedIdentity: AgentStatusIpcPayload = { - paneKey: 'tab:external-live', - connectionId: null, - receivedAt: 1, - stateStartedAt: 1, - state: 'working', - prompt: 'test', - agentType: 'gemini', - providerSession: { key: 'session_id', id: 'session-live' }, - providerSessionOnly: true - } - - const { filePath, result } = await attemptDelete(() => - resolveAiVaultSessionLiveness( - { agent: 'gemini', sessionId: 'session-live' }, - { - listProcesses: async () => [], - getStatusSnapshot: () => [retainedIdentity], - inspectForegroundProcess: vi.fn(), - getStatusPtyId: () => null, - getAgentHint: () => null - } - ) - ) - - expect.soft(result).toEqual({ - outcome: 'rejected', - agent: 'gemini', - reason: 'session-liveness-unknown' - }) - expect.soft(existsSync(filePath), 'dismissed live transcript must survive on disk').toBe(true) - }) - - it('fails closed when authoritative liveness is unavailable', async () => { - trashItemMock.mockImplementation((path: string) => rm(path)) - - const { filePath, result } = await attemptDelete(async () => 'unknown') - - expect(result).toEqual({ - outcome: 'rejected', - agent: 'gemini', - reason: 'session-liveness-unknown' - }) - expect(existsSync(filePath), 'unknown-liveness transcript must survive on disk').toBe(true) - }) -}) diff --git a/src/main/ai-vault/session-delete.test.ts b/src/main/ai-vault/session-delete.test.ts index 3c6f86f43a7..cacb0dbbda5 100644 --- a/src/main/ai-vault/session-delete.test.ts +++ b/src/main/ai-vault/session-delete.test.ts @@ -33,21 +33,13 @@ vi.mock('../wsl-unc-delete', () => ({ WslDeleteValidationError: WslDeleteValidationErrorMock })) -import { deleteAiVaultSessionFile as deleteAiVaultSessionFileWithLiveness } from './session-delete' +import { deleteAiVaultSessionFile } from './session-delete' const HOME = join('/tmp', 'orca-ai-vault-delete-exec-fixture-home') const GEMINI_ROOT = join(HOME, '.gemini', 'tmp') const CLAUDE_ROOT = join(HOME, '.claude', 'projects') const ROVO_ROOT = join(HOME, '.rovodev', 'sessions') -function deleteAiVaultSessionFile( - args: Parameters[0] -) { - return deleteAiVaultSessionFileWithLiveness(args, { - getSessionLiveness: async () => 'not-live' - }) -} - function enoent(): NodeJS.ErrnoException { const error = new Error('not found') as NodeJS.ErrnoException error.code = 'ENOENT' diff --git a/src/main/ai-vault/session-delete.ts b/src/main/ai-vault/session-delete.ts index fb6876904ae..4676749068b 100644 --- a/src/main/ai-vault/session-delete.ts +++ b/src/main/ai-vault/session-delete.ts @@ -2,10 +2,8 @@ import { lstat, realpath } from 'node:fs/promises' import { shell } from 'electron' import type { AiVaultDeleteSessionResult, - AiVaultSessionDeleteRemoval, - AiVaultSessionLiveness + AiVaultSessionDeleteRemoval } from '../../shared/ai-vault-session-deletion' -import type { AiVaultAgent } from '../../shared/ai-vault-types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { validateAiVaultSessionDeleteTarget, @@ -19,14 +17,7 @@ import { isENOENT } from '../ipc/filesystem-auth' // Never throws: IPC payloads are untyped at runtime, so a bad input and an fs // error both resolve to a discriminated result the handler can render. export async function deleteAiVaultSessionFile( - args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId?: string }, - deps: { - getSessionLiveness: (target: { - agent: AiVaultAgent - sessionId: string | undefined - filePath: string - }) => Promise - } + args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId?: string } ): Promise { const validation = validateAiVaultSessionDeleteTarget(args) if (!validation.allowed) { @@ -34,24 +25,6 @@ export async function deleteAiVaultSessionFile( } const { agent, removals } = validation - let liveness: AiVaultSessionLiveness = 'unknown' - try { - liveness = await deps.getSessionLiveness({ - agent, - sessionId: args.sessionId, - filePath: validation.resolvedPath - }) - } catch { - // Inspection failure is unknown, never permission to delete. - } - if (liveness !== 'not-live') { - return { - outcome: 'rejected', - agent, - reason: liveness === 'live' ? 'session-live' : 'session-liveness-unknown' - } - } - try { for (const removal of removals) { const rejection = await removeOne(removal) diff --git a/src/main/ai-vault/session-liveness.test.ts b/src/main/ai-vault/session-liveness.test.ts deleted file mode 100644 index c4eb2a70328..00000000000 --- a/src/main/ai-vault/session-liveness.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -import type { PtyProcessInfo } from '../providers/pty-process-info' -import { - readAiVaultSessionIdentity, - resolveAiVaultSessionLiveness, - type AiVaultSessionLivenessDependencies -} from './session-liveness' - -function processInfo(id: string, terminalHandle = `term_${id}`): PtyProcessInfo { - return { id, terminalHandle, cwd: '/workspace', title: 'terminal' } -} - -function status(args: { - sessionId: string - ptyId: string - connectionId?: string | null -}): AgentStatusIpcPayload { - return { - paneKey: `tab:${args.ptyId}`, - terminalHandle: `term_${args.ptyId}`, - connectionId: args.connectionId ?? null, - receivedAt: 1, - stateStartedAt: 1, - state: 'working', - prompt: 'test', - agentType: 'gemini', - providerSession: { key: 'session_id', id: args.sessionId } - } -} - -function dependencies( - overrides: Partial = {} -): AiVaultSessionLivenessDependencies { - return { - listProcesses: async () => [], - getStatusSnapshot: () => [], - inspectForegroundProcess: async () => ({ available: true, process: 'zsh' }), - getStatusPtyId: (row) => row.terminalHandle?.slice('term_'.length) ?? null, - getAgentHint: () => null, - ...overrides - } -} - -describe('resolveAiVaultSessionLiveness', () => { - it('binds renderer identity to the session parsed from the validated transcript', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-delete-identity-')) - const filePath = join(root, 'session.json') - await writeFile( - filePath, - JSON.stringify({ - sessionId: 'authoritative-session', - startTime: '2026-08-07T00:00:00.000Z', - lastUpdated: '2026-08-07T00:01:00.000Z', - messages: [{ type: 'user', content: 'test' }] - }) - ) - try { - await expect( - readAiVaultSessionIdentity({ - agent: 'gemini', - sessionId: 'spoofed-session', - filePath - }) - ).resolves.toEqual({ outcome: 'unknown' }) - await expect( - readAiVaultSessionIdentity({ - agent: 'gemini', - sessionId: 'authoritative-session', - filePath - }) - ).resolves.toEqual({ outcome: 'found', sessionId: 'authoritative-session' }) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) - - it('finds a paired-owned live session without consulting a renderer pane snapshot', async () => { - const deps = dependencies({ - listProcesses: async () => [processInfo('paired-pty')], - getStatusSnapshot: () => [status({ sessionId: 'session-live', ptyId: 'paired-pty' })], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps) - ).resolves.toBe('live') - }) - - it('reads hook identity after a controlled process-inventory barrier', async () => { - let releaseInventory: (processes: PtyProcessInfo[]) => void = () => {} - const inventory = new Promise((resolve) => { - releaseInventory = resolve - }) - let statuses = [status({ sessionId: 'session-other', ptyId: 'paired-pty' })] - const deps = dependencies({ - listProcesses: () => inventory, - getStatusSnapshot: () => statuses, - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - const liveness = resolveAiVaultSessionLiveness( - { agent: 'gemini', sessionId: 'session-live' }, - deps - ) - statuses = [status({ sessionId: 'session-live', ptyId: 'paired-pty' })] - releaseInventory([processInfo('paired-pty')]) - - await expect(liveness).resolves.toBe('live') - }) - - it('reads hook identity after a controlled foreground-inspection barrier', async () => { - let markInspectionStarted: () => void = () => {} - const inspectionStarted = new Promise((resolve) => { - markInspectionStarted = resolve - }) - let releaseInspection: (inspection: { - available: boolean - process: string | null - }) => void = () => {} - const inspection = new Promise<{ available: boolean; process: string | null }>((resolve) => { - releaseInspection = resolve - }) - let statuses = [status({ sessionId: 'session-other', ptyId: 'managed-pty' })] - const deps = dependencies({ - listProcesses: async () => [processInfo('managed-pty')], - getStatusSnapshot: () => statuses, - inspectForegroundProcess: () => { - markInspectionStarted() - return inspection - } - }) - - const liveness = resolveAiVaultSessionLiveness( - { agent: 'gemini', sessionId: 'session-live' }, - deps - ) - await inspectionStarted - statuses = [status({ sessionId: 'session-live', ptyId: 'managed-pty' })] - releaseInspection({ available: true, process: 'gemini' }) - - await expect(liveness).resolves.toBe('live') - }) - - it('treats WSL hook ownership as local authority', async () => { - const deps = dependencies({ - listProcesses: async () => [processInfo('wsl-pty')], - getStatusSnapshot: () => [ - status({ sessionId: 'session-live', ptyId: 'wsl-pty', connectionId: 'wsl:Ubuntu' }) - ], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps) - ).resolves.toBe('live') - }) - - it('does not use SSH identity as local liveness evidence', async () => { - const deps = dependencies({ - listProcesses: async () => [processInfo('local-pty')], - getStatusSnapshot: () => [ - status({ sessionId: 'session-live', ptyId: 'local-pty', connectionId: 'ssh:dev-box' }) - ], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps) - ).resolves.toBe('unknown') - }) - - it('returns not-live when every local Gemini process owns another known session', async () => { - const deps = dependencies({ - listProcesses: async () => [processInfo('other-pty')], - getStatusSnapshot: () => [status({ sessionId: 'session-other', ptyId: 'other-pty' })], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) - ).resolves.toBe('not-live') - }) - - it.each([ - ['missing session identity', dependencies(), undefined], - [ - 'unavailable process inventory', - dependencies({ - listProcesses: async () => { - throw new Error('daemon offline') - } - }), - 'session-live' - ], - [ - 'unattributed live agent process', - dependencies({ - listProcesses: async () => [processInfo('unknown-pty')], - inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) - }), - 'session-live' - ] - ])('preserves unknown for %s', async (_name, deps, sessionId) => { - await expect(resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId }, deps)).resolves.toBe( - 'unknown' - ) - }) - - it('bounds foreground inspection concurrency', async () => { - let active = 0 - let maxActive = 0 - const inspectForegroundProcess = vi.fn(async () => { - active += 1 - maxActive = Math.max(maxActive, active) - await new Promise((resolve) => queueMicrotask(resolve)) - active -= 1 - return { available: true, process: 'zsh' } - }) - const deps = dependencies({ - listProcesses: async () => - Array.from({ length: 20 }, (_, index) => processInfo(`shell-${index}`)), - inspectForegroundProcess - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) - ).resolves.toBe('not-live') - expect(inspectForegroundProcess).toHaveBeenCalledTimes(20) - expect(maxActive).toBeLessThanOrEqual(8) - }) - - it('fails closed before inspecting an oversized inventory', async () => { - const inspectForegroundProcess = vi.fn() - const deps = dependencies({ - listProcesses: async () => - Array.from({ length: 513 }, (_, index) => processInfo(`shell-${index}`)), - inspectForegroundProcess - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) - ).resolves.toBe('unknown') - expect(inspectForegroundProcess).not.toHaveBeenCalled() - }) - - it('does not start foreground inspections after the shared deadline', async () => { - const inspectForegroundProcess = vi.fn() - const deps = dependencies({ - deadlineMs: 0, - listProcesses: async () => [processInfo('late-pty')], - inspectForegroundProcess - }) - - await expect( - resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) - ).resolves.toBe('unknown') - expect(inspectForegroundProcess).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ai-vault/session-liveness.ts b/src/main/ai-vault/session-liveness.ts deleted file mode 100644 index 6f7170687ad..00000000000 --- a/src/main/ai-vault/session-liveness.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { isShellProcess } from '../../shared/agent-detection' -import { recognizeAgentProcess } from '../../shared/agent-process-recognition' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -import type { AiVaultSessionLiveness } from '../../shared/ai-vault-session-deletion' -import type { AiVaultAgent } from '../../shared/ai-vault-types' -import type { PtyProcessInfo } from '../providers/pty-process-info' -import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' -import { lstat } from 'node:fs/promises' -import { parseAgentSessionFileCached } from './session-scanner-parse-cache' - -const MAX_LIVENESS_PROCESSES = 512 -const PROCESS_INSPECTION_CONCURRENCY = 8 - -type ProcessInspection = { - available: boolean - process: string | null -} - -type AgentProcessInspection = 'target-agent' | 'unknown' | 'other' - -export type AiVaultSessionLivenessDependencies = { - deadlineMs?: number - listProcesses: () => Promise - getStatusSnapshot: () => AgentStatusIpcPayload[] - inspectForegroundProcess: (ptyId: string) => Promise - getStatusPtyId: (status: AgentStatusIpcPayload) => string | null - getAgentHint: (process: PtyProcessInfo) => string | null -} - -export type AiVaultSessionIdentityRead = - | { outcome: 'found'; sessionId: string } - | { outcome: 'missing' } - | { outcome: 'unknown' } - -/** Binds the requested identity to the validated transcript on disk. */ -export async function readAiVaultSessionIdentity(target: { - agent: AiVaultAgent - sessionId: string | undefined - filePath: string -}): Promise { - try { - const fileStats = await lstat(target.filePath) - if (!fileStats.isFile()) { - return { outcome: 'unknown' } - } - const session = await parseAgentSessionFileCached( - { - agent: target.agent, - file: { - path: target.filePath, - mtimeMs: fileStats.mtimeMs, - modifiedAt: fileStats.mtime.toISOString(), - sizeBytes: fileStats.size - }, - codexHome: null - }, - process.platform - ) - return session?.sessionId && session.sessionId === target.sessionId - ? { outcome: 'found', sessionId: session.sessionId } - : { outcome: 'unknown' } - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'ENOENT' - ? { outcome: 'missing' } - : { outcome: 'unknown' } - } -} - -function isLocalStatus(status: AgentStatusIpcPayload): boolean { - return status.connectionId === null || isWslHookRelayConnectionId(status.connectionId) -} - -function isValidSessionId(sessionId: string | undefined): sessionId is string { - return ( - typeof sessionId === 'string' && - sessionId.length > 0 && - sessionId.length <= 512 && - sessionId.trim() === sessionId - ) -} - -async function inspectInBatches( - values: readonly T[], - inspect: (value: T) => Promise, - deadlineMs?: number -): Promise { - const results: AgentProcessInspection[] = [] - let nextIndex = 0 - const worker = async (): Promise => { - while (nextIndex < values.length) { - const index = nextIndex - nextIndex += 1 - if (deadlineMs !== undefined && Date.now() >= deadlineMs) { - results[index] = 'unknown' - continue - } - results[index] = await inspect(values[index]!) - } - } - await Promise.all( - Array.from({ length: Math.min(PROCESS_INSPECTION_CONCURRENCY, values.length) }, worker) - ) - return results -} - -/** Resolves owning-runtime liveness without treating unavailable evidence as absence. */ -export async function resolveAiVaultSessionLiveness( - target: { agent: AiVaultAgent; sessionId: string | undefined }, - deps: AiVaultSessionLivenessDependencies -): Promise { - if (!isValidSessionId(target.sessionId)) { - return 'unknown' - } - - let processes: PtyProcessInfo[] - try { - processes = await deps.listProcesses() - } catch { - return 'unknown' - } - if (processes.length > MAX_LIVENESS_PROCESSES) { - return 'unknown' - } - - const processInspections = await inspectInBatches( - processes, - async (process) => { - let inspection: ProcessInspection - try { - inspection = await deps.inspectForegroundProcess(process.id) - } catch { - return 'unknown' - } - if (!inspection.available) { - return 'unknown' - } - - const recognizedAgent = recognizeAgentProcess(inspection.process)?.agent ?? null - const hintedAgent = deps.getAgentHint(process) - const ownsTargetAgent = - recognizedAgent === target.agent || - (recognizedAgent === null && - (inspection.process === null || !isShellProcess(inspection.process)) && - hintedAgent === target.agent) - if (!ownsTargetAgent) { - return 'other' - } - return 'target-agent' - }, - deps.deadlineMs - ) - - let localStatuses: AgentStatusIpcPayload[] - try { - localStatuses = deps.getStatusSnapshot().filter(isLocalStatus) - } catch { - return 'unknown' - } - const processIds = new Set(processes.map((process) => process.id)) - const hasUnmatchedTargetStatus = localStatuses.some( - (status) => - status.agentType === target.agent && - status.providerSession?.id === target.sessionId && - !processIds.has(deps.getStatusPtyId(status) ?? '') - ) - if (hasUnmatchedTargetStatus) { - return 'unknown' - } - - const results = processInspections.map((inspection, index) => { - if (inspection !== 'target-agent') { - return inspection - } - const identities = localStatuses.filter( - (status) => - status.agentType === target.agent && deps.getStatusPtyId(status) === processes[index]!.id - ) - if (identities.some((status) => status.providerSession?.id === target.sessionId)) { - return 'live' - } - return identities.some((status) => status.providerSession) ? 'other' : 'unknown' - }) - if (results.includes('live')) { - return 'live' - } - return results.includes('unknown') ? 'unknown' : 'not-live' -} diff --git a/src/main/ipc/ai-vault-delete.ts b/src/main/ipc/ai-vault-delete.ts index 3f3a1c8abd4..a221cc28ceb 100644 --- a/src/main/ipc/ai-vault-delete.ts +++ b/src/main/ipc/ai-vault-delete.ts @@ -8,19 +8,13 @@ import { invalidateSessionParseCacheEntry } from '../ai-vault/session-scanner-pa import type { AiVaultAgent } from '../../shared/ai-vault-types' import type { AiVaultDeleteSessionArgs, - AiVaultDeleteSessionResult, - AiVaultSessionLiveness + AiVaultDeleteSessionResult } from '../../shared/ai-vault-session-deletion' // Which cache backs the multi-host list is ai-vault.ts's concern, so its // invalidation is injected rather than reached into from here. type AiVaultDeleteDeps = { invalidateMultiHostListCache: () => void - getSessionLiveness: (target: { - agent: AiVaultAgent - sessionId: string | undefined - filePath: string - }) => Promise } // Binds the delete orchestration to the caller's cache-invalidation seam. @@ -40,16 +34,13 @@ export async function deleteAiVaultSession( // The validator tolerates a malformed agent/filePath but destructures `args`, // so an absent payload is defaulted here to keep the never-throws boundary. const wslHomeDirs = await getAiVaultWslHomeDirs() - const result = await deleteAiVaultSessionFile( - { - agent: args?.agent as AiVaultAgent, - sessionId: args?.sessionId, - filePath: args?.filePath ?? '', - executionHostId: args?.executionHostId, - wslHomeDirs - }, - deps - ) + const result = await deleteAiVaultSessionFile({ + agent: args?.agent as AiVaultAgent, + sessionId: args?.sessionId, + filePath: args?.filePath ?? '', + executionHostId: args?.executionHostId, + wslHomeDirs + }) if (result.outcome === 'deleted') { // Three caches could otherwise resurrect it: the desktop per-host leg diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index e36708e18b0..9af91f92a33 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -800,8 +800,7 @@ describe('deleteAiVaultSession', () => { sessionId: args.sessionId, filePath: args.filePath, executionHostId: 'local' - }), - expect.objectContaining({ getSessionLiveness: expect.any(Function) }) + }) ) expect(mocks.invalidateAiVaultSessionListCache).toHaveBeenCalledTimes(1) expect(mocks.invalidateSessionParseCacheEntry).toHaveBeenCalledWith(args.filePath) @@ -850,8 +849,7 @@ describe('deleteAiVaultSession', () => { reason: 'invalid-path' }) expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith( - expect.objectContaining({ filePath: '' }), - expect.objectContaining({ getSessionLiveness: expect.any(Function) }) + expect.objectContaining({ filePath: '' }) ) expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 3f4e2d49af6..c2f5be216c7 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -71,7 +71,6 @@ type AiVaultHandlerOptions = AiVaultSessionSources & getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner resolveRuntimeAiVaultSessionTitles?: RuntimeAiVaultSessionTitleResolver - getSessionLiveness?: Parameters[1]['getSessionLiveness'] } let scanCoordinator = new AiVaultScanCoordinator() @@ -80,10 +79,7 @@ const listCancellations = createSenderScopedRequestCancellations() // Shared by the IPC registration and the test internals: a delete must drop // the multi-host leg cache, which this module owns the only caller of. const aiVaultDeleteDeps = { - invalidateMultiHostListCache: invalidateAiVaultHostLegCache, - getSessionLiveness: ( - target: Parameters>[0] - ) => handlerOptions.getSessionLiveness?.(target) ?? Promise.resolve('unknown' as const) + invalidateMultiHostListCache: invalidateAiVaultHostLegCache } const resolveAiVaultSessionTitles = ( diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index b685f470eb0..607aa08ce3e 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -227,8 +227,7 @@ export function registerCoreHandlers( resolveRuntimeAiVaultSessionTitles: async (environmentId, args) => resolveRuntimeAiVaultSessionTitles(app.getPath('userData'), environmentId, args), prepareRuntimeSessionResume: async (environmentId, args) => - prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args), - getSessionLiveness: (target) => runtime.getAiVaultSessionLiveness(target) + prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args) }) registerNativeChatHandlers() registerClipboardHandlers(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 85888b94615..f0127702406 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -490,16 +490,11 @@ import { listAiVaultSessions } from '../ai-vault/cached-session-list' import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver' -import { - readAiVaultSessionIdentity, - resolveAiVaultSessionLiveness -} from '../ai-vault/session-liveness' -import type { AiVaultAgent, AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' +import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import type { AiVaultSessionTitleRequest, AiVaultSessionTitlesResult } from '../../shared/ai-vault-session-title' -import type { AiVaultSessionLiveness } from '../../shared/ai-vault-session-deletion' import type { AiVaultPrepareSessionResumeArgs, AiVaultPrepareSessionResumeResult @@ -4990,74 +4985,6 @@ export class OrcaRuntimeService { return resolveLocalAiVaultSessionTitles(requests, signal) } - async getAiVaultSessionLiveness(target: { - agent: AiVaultAgent - sessionId: string | undefined - filePath: string - }): Promise { - const provider = this.getLocalProviderFn?.() - if (!provider || !this.getAgentProviderSessionSnapshotFn) { - return 'unknown' - } - const identity = await readAiVaultSessionIdentity(target) - if (identity.outcome !== 'found') { - return 'unknown' - } - const deadlineMs = Date.now() + 3_000 - return await resolveAiVaultSessionLiveness( - { - agent: target.agent, - sessionId: identity.sessionId - }, - { - deadlineMs, - listProcesses: async () => { - const result = await withTimeoutResult( - provider.listProcesses({ deadlineMs }), - Math.max(1, deadlineMs - Date.now()) - ) - if (!result.ok) { - throw new Error('agent_session_ownership_unknown') - } - return result.value - }, - getStatusSnapshot: this.getAgentProviderSessionSnapshotFn, - inspectForegroundProcess: async (ptyId) => { - const result = await withTimeoutResult( - provider.getForegroundProcess(ptyId), - Math.max(1, deadlineMs - Date.now()) - ) - return result.ok - ? { available: true, process: result.value } - : { available: false, process: null } - }, - getStatusPtyId: (status) => { - if (status.terminalHandle) { - const live = this.getLivePtyForHandle(status.terminalHandle) - if (live) { - return live.pty.ptyId - } - for (const [ptyId, handle] of this.handleByPtyId) { - if (handle === status.terminalHandle) { - return ptyId - } - } - } - return this.getPtyRecordForPaneKey(status.paneKey)?.ptyId ?? null - }, - getAgentHint: (process) => { - const pty = this.ptysById.get(process.id) - const runtimeHint = pty?.foregroundAgent ?? pty?.launchAgent - if (runtimeHint) { - return runtimeHint - } - const ownerAgents = new Set(process.agentSessionOwners?.map((owner) => owner.claim.agent)) - return ownerAgents.size === 1 ? ([...ownerAgents][0] ?? null) : null - } - } - ) - } - prepareAiVaultSessionResume( args: AiVaultPrepareSessionResumeArgs ): Promise { diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 9e2f1702f7c..e4af4f3e972 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -85,7 +85,7 @@ export function VaultSessionRow({ const detailsId = getSessionDetailsId(session.id) const latestTurn = latestSessionConversationTurn(session) // Computed once so the dropdown menu and the context menu never disagree. - const deleteBlockedReason = aiVaultSessionDeleteBlockedReason(session, liveState) + const deleteBlockedReason = aiVaultSessionDeleteBlockedReason(session) const requestDelete = (): void => onRequestDelete(session) const detailsTooltip = detailsExpanded ? translate('auto.components.right.sidebar.AiVaultSessionRow.hideDetails', 'Hide Details') diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts index 49e8d39e123..3e2a370e8f5 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts @@ -5,7 +5,6 @@ import { aiVaultSessionDeleteBlockedReason } from './ai-vault-session-deletabili // assertions pin the English copy as well as the gate order. const NON_LOCAL = 'Only sessions on this device can be deleted.' const SYNTHETIC = "This session can't be deleted from Orca." -const LIVE = 'This session is still running — wait for it to finish before deleting.' const localGeminiSession = { agent: 'gemini' as const, @@ -28,28 +27,6 @@ describe('aiVaultSessionDeleteBlockedReason', () => { ).toBeNull() }) - it('tells the user to wait while the agent is still running', () => { - for (const live of ['working', 'blocked', 'waiting'] as const) { - expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, live)).toBe(LIVE) - } - }) - - it('offers Delete for a finished session (done) and one with no live state', () => { - expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, 'done')).toBeNull() - expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, null)).toBeNull() - }) - - it('keeps the permanent reason over "running" for an unsupported live session', () => { - // A live but unsupported agent stays "unsupported" — it would never become - // deletable, so "wait for it to finish" would mislead. - expect( - aiVaultSessionDeleteBlockedReason( - { agent: 'codex', executionHostId: 'local', filePath: '/home/user/.codex/x.jsonl' }, - 'working' - ) - ).toBe("Codex sessions can't be deleted from Orca.") - }) - it('blocks ssh- and runtime-hosted sessions regardless of agent', () => { for (const executionHostId of ['ssh:dev-box', 'runtime:gpu-box'] as const) { expect(aiVaultSessionDeleteBlockedReason({ ...localGeminiSession, executionHostId })).toBe( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts index 033ebd87c81..87007c376af 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts @@ -1,6 +1,5 @@ import { isAiVaultDeletableAgent } from '../../../../shared/ai-vault-session-deletion' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { AgentStatusState } from '../../../../shared/agent-status-types' import { translate } from '@/i18n/i18n' import { agentLabel } from './ai-vault-session-filters' import { @@ -8,11 +7,6 @@ import { isSyntheticAiVaultSessionPath } from './ai-vault-session-path-actions' -// Matches the active-dot rule in ai-vault-session-row-display. -function isSessionLive(liveState: AgentStatusState | null | undefined): boolean { - return liveState != null && liveState !== 'done' -} - /** * Why Delete is unavailable for this session, as the tooltip text to show — or * null when it is offered. Each message says which sessions are affected, never @@ -25,8 +19,7 @@ function isSessionLive(liveState: AgentStatusState | null | undefined): boolean * it does: both consult the same shared agent set and host/synthetic predicates. */ export function aiVaultSessionDeleteBlockedReason( - session: Pick, - liveState?: AgentStatusState | null + session: Pick ): string | null { if (!canUseLocalAiVaultSessionPathActions(session.executionHostId)) { return translate( @@ -47,14 +40,5 @@ export function aiVaultSessionDeleteBlockedReason( { value0: agentLabel(session.agent) } ) } - // Last, so an otherwise-deletable session reads as "wait for it to finish" - // rather than a permanent reason. Trashing a live transcript would drop the - // writes the agent is still appending. - if (isSessionLive(liveState)) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonSessionLive', - 'This session is still running — wait for it to finish before deleting.' - ) - } return null } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a1eedc20694..08fd9b389c8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11738,8 +11738,7 @@ "delete": "Delete", "deleteReasonNonLocalHost": "Only sessions on this device can be deleted.", "deleteReasonSyntheticPath": "This session can't be deleted from Orca.", - "deleteReasonUnsupportedAgent": "{{value0}} sessions can't be deleted from Orca.", - "deleteReasonSessionLive": "This session is still running — wait for it to finish before deleting." + "deleteReasonUnsupportedAgent": "{{value0}} sessions can't be deleted from Orca." }, "AiVaultSessionDeleteDialog": { "title": "Delete this session?", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index dc1024c8b27..d84aa89b132 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11318,8 +11318,7 @@ "delete": "Eliminar", "deleteReasonNonLocalHost": "Solo se pueden eliminar las sesiones de este dispositivo.", "deleteReasonSyntheticPath": "Esta sesión no se puede eliminar desde Orca.", - "deleteReasonUnsupportedAgent": "Las sesiones de {{value0}} no se pueden eliminar desde Orca.", - "deleteReasonSessionLive": "Esta sesión aún se está ejecutando: espera a que termine antes de eliminarla." + "deleteReasonUnsupportedAgent": "Las sesiones de {{value0}} no se pueden eliminar desde Orca." }, "AiVaultSessionDeleteDialog": { "title": "¿Eliminar esta sesión?", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3d7991b1475..76f154d39e8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11318,8 +11318,7 @@ "delete": "削除", "deleteReasonNonLocalHost": "このデバイス上のセッションのみ削除できます。", "deleteReasonSyntheticPath": "このセッションは Orca では削除できません。", - "deleteReasonUnsupportedAgent": "{{value0}} のセッションは Orca では削除できません。", - "deleteReasonSessionLive": "このセッションはまだ実行中です — 削除する前に完了を待ってください。" + "deleteReasonUnsupportedAgent": "{{value0}} のセッションは Orca では削除できません。" }, "AiVaultSessionDeleteDialog": { "title": "このセッションを削除しますか?", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c04d93c1629..c86caa3e852 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11318,8 +11318,7 @@ "delete": "삭제", "deleteReasonNonLocalHost": "이 기기에 있는 세션만 삭제할 수 있습니다.", "deleteReasonSyntheticPath": "이 세션은 Orca에서 삭제할 수 없습니다.", - "deleteReasonUnsupportedAgent": "{{value0}} 세션은 Orca에서 삭제할 수 없습니다.", - "deleteReasonSessionLive": "이 세션은 아직 실행 중입니다 — 삭제하려면 완료될 때까지 기다리세요." + "deleteReasonUnsupportedAgent": "{{value0}} 세션은 Orca에서 삭제할 수 없습니다." }, "AiVaultSessionDeleteDialog": { "title": "이 세션을 삭제할까요?", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 85b4cd051ba..62090dd10ea 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11338,8 +11338,7 @@ "delete": "删除", "deleteReasonNonLocalHost": "只能删除此设备上的会话。", "deleteReasonSyntheticPath": "此会话无法在 Orca 中删除。", - "deleteReasonUnsupportedAgent": "{{value0}} 会话无法在 Orca 中删除。", - "deleteReasonSessionLive": "此会话仍在运行 — 请等待其完成后再删除。" + "deleteReasonUnsupportedAgent": "{{value0}} 会话无法在 Orca 中删除。" }, "AiVaultSessionDeleteDialog": { "title": "要删除此会话吗?", diff --git a/src/shared/ai-vault-session-deletion.ts b/src/shared/ai-vault-session-deletion.ts index 7fdde68b0e4..9e25f632abe 100644 --- a/src/shared/ai-vault-session-deletion.ts +++ b/src/shared/ai-vault-session-deletion.ts @@ -4,7 +4,8 @@ import type { ExecutionHostId } from './execution-host' // IPC payload for aiVault:deleteSession. export type AiVaultDeleteSessionArgs = { agent: AiVaultAgent - // Optional for mixed renderer/main versions; main rejects missing identity. + // Optional for mixed renderer/main versions. Main ignores this field on delete + // (path + host + agent validation only; no identity/liveness check). sessionId?: string filePath: string // The session's host; only a local session may be deleted. @@ -71,10 +72,6 @@ export type AiVaultSessionDeleteRejectionCode = // fs-side guard: lstat disagrees with the removal's declared kind (a symlink, // or a file where the plan expects a directory). | 'unexpected-target-kind' - | 'session-live' - | 'session-liveness-unknown' - -export type AiVaultSessionLiveness = 'live' | 'not-live' | 'unknown' // One path the executor removes. A `kind` mismatch on disk is a rejection, // never a coerced delete. `roots` are what the path's realpath must still diff --git a/tests/e2e/terminal-macos-system-key-remap.spec.ts b/tests/e2e/terminal-macos-system-key-remap.spec.ts index 711b7a6b602..f858f224386 100644 --- a/tests/e2e/terminal-macos-system-key-remap.spec.ts +++ b/tests/e2e/terminal-macos-system-key-remap.spec.ts @@ -149,7 +149,12 @@ test.describe('Terminal macOS system key remap', () => { expect(received).toEqual([Buffer.from(`${committed}\n`).toString('hex')]) completed = true } finally { - await closeTerminalImePaneArena(arena, testInfo, `${arm.slug}-${layout.label}`, !completed) + await closeTerminalImePaneArena( + arena, + testInfo, + `${arm.slug}-${layout.label}`, + !completed + ) removeTerminalImeByteReader(reader) } }) From 75650936f4283e2902ae7cfe522feef9912d64d7 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 9 Aug 2026 18:42:15 -0700 Subject: [PATCH 010/271] fix(source-control): hydrate Windows shell profile PATH (#13418) --- src/main/index.ts | 36 ++- src/main/ipc/settings.ts | 2 +- src/main/runtime/orca-runtime.test.ts | 2 +- src/main/runtime/orca-runtime.ts | 2 +- src/main/startup/hydrate-shell-path.test.ts | 20 ++ src/main/startup/hydrate-shell-path.ts | 177 +++++++++-- .../hydrate-shell-path.windows.test.ts | 297 ++++++++++++++++++ .../windows-shell-path-hydration.test.ts | 214 +++++++++++++ .../startup/windows-shell-path-hydration.ts | 107 +++++++ 9 files changed, 822 insertions(+), 35 deletions(-) create mode 100644 src/main/startup/hydrate-shell-path.windows.test.ts create mode 100644 src/main/startup/windows-shell-path-hydration.test.ts create mode 100644 src/main/startup/windows-shell-path-hydration.ts diff --git a/src/main/index.ts b/src/main/index.ts index a8153952c02..ddf79fd9455 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -159,6 +159,7 @@ import { recoverLegacyWorkerTerminalsForRendererStartup } from './startup/legacy import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier' import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' +import { createWindowsShellPathHydration } from './startup/windows-shell-path-hydration' import { acquireSingleInstanceLock, logSingleInstanceLockBypass, @@ -2124,6 +2125,21 @@ void app.whenReady().then(async () => { const activeOrcaProfile = ensureActiveOrcaProfile() store = new Store({ dataFile: activeOrcaProfile.dataFile }) + const windowsShellPathHydration = createWindowsShellPathHydration() + if (process.platform === 'win32') { + const settings = store.getSettings() + if (app.isPackaged) { + void windowsShellPathHydration.hydrate( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } else { + windowsShellPathHydration.configure( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } + } wslHookRelayManager.setManagedHookSettingsResolver(() => store?.getSettings() ?? null) logStartupMilestone('store-loaded') // Why: apply initial fallback WSL distro from store settings for global git/CLI calls. @@ -2133,6 +2149,22 @@ void app.whenReady().then(async () => { // Why: synchronize fallback WSL distro updates to runner. setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null) } + if ( + ('terminalWindowsShell' in updates || 'terminalWindowsPowerShellImplementation' in updates) && + process.platform === 'win32' + ) { + if (app.isPackaged) { + void windowsShellPathHydration.hydrate( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } else { + windowsShellPathHydration.configure( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } + } if ('showMenuBarIcon' in updates) { // Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live. syncMacMenuBarIcon(settings.showMenuBarIcon !== false) @@ -2742,7 +2774,7 @@ void app.whenReady().then(async () => { if (isAgentStatusHooksEnabled(store.getSettings())) { const managedHookStore = store void applyAgentStatusHooksEnabled(true, managedHookStore.getSettings(), { - shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32', + shouldHydrateShellPath: app.isPackaged, onInstallError: recordManagedHookInstallFailure, shouldContinue: (agent) => { const settings = managedHookStore.getSettings() @@ -2916,6 +2948,8 @@ void app.whenReady().then(async () => { } }) + // Why: Git hooks inherit process.env, so Source Control must not open before profile PATH settles. + await windowsShellPathHydration.whenReady() startTerminalRuntimeStartupServices() app.on('activate', handleMacAppActivation) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 275ba54c82a..df3817c949c 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -172,7 +172,7 @@ export function registerSettingsHandlers( if (hookSettingChanged) { try { await applyAgentStatusHooksEnabled(result.agentStatusHooksEnabled, result, { - shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32', + shouldHydrateShellPath: app.isPackaged, onInstallError: recordManagedHookInstallFailure, shouldContinue: (agent) => { const settings = store.getSettings() diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ec656c70144..7967d422f46 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1815,7 +1815,7 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ disabledTuiAgents: ['claude'] }), expect.objectContaining({ shouldContinue: expect.any(Function), - shouldHydrateShellPath: process.platform !== 'win32' + shouldHydrateShellPath: true }) ) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f0127702406..526acea823b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3607,7 +3607,7 @@ export class OrcaRuntimeService { return } await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, { - shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32', + shouldHydrateShellPath: app.isPackaged, onInstallError: recordManagedHookInstallFailure, shouldContinue: (agent) => { const current = this.store?.getSettings() diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index 7ff6ee2ae81..316baad7280 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -93,6 +93,26 @@ describe('hydrateShellPath', () => { expect(spawnCount).toBe(2) }) + it('continues the probe queue after a rejected hydration', async () => { + const rejectedSpawner = vi.fn(async () => { + throw new Error('profile failed') + }) + const successfulSpawner = vi.fn(async () => ({ + segments: ['/current'], + ok: true, + failureReason: 'none' + })) + + await expect( + hydrateShellPath({ shellOverride: '/bin/zsh', spawner: rejectedSpawner, force: true }) + ).rejects.toThrow('profile failed') + await expect( + hydrateShellPath({ shellOverride: '/bin/bash', spawner: successfulSpawner, force: true }) + ).resolves.toEqual({ segments: ['/current'], ok: true, failureReason: 'none' }) + + expect(successfulSpawner).toHaveBeenCalledWith('/bin/bash') + }) + it('returns failureReason:no_shell when no shell is available (Windows path)', async () => { const result = await hydrateShellPath({ shellOverride: null, diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 9411b0437d3..87747399615 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -1,18 +1,14 @@ import { spawn } from 'node:child_process' -import { delimiter } from 'node:path' +import { delimiter, win32 as pathWin32 } from 'node:path' import type { ShellHydrationFailureReason } from '../../shared/types' +import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell' -// Why: GUI-launched Electron on macOS/Linux inherits a minimal PATH from launchd -// that does not include dirs appended by the user's shell rc files (~/.zshrc, -// ~/.bashrc). Tools installed into ~/.opencode/bin, ~/.cargo/bin, pyenv/volta +// Why: GUI-launched Electron can miss PATH entries added by shell profiles. +// Tools installed into ~/.opencode/bin, ~/.cargo/bin, pyenv/volta/fnm // shims, and countless other user-local locations end up invisible to our // `which` probe even though they work fine from Terminal (see stablyai/orca#829). // -// Rather than play whack-a-mole adding every agent's install dir to a hardcoded -// list, we spawn the user's login shell once per app session and read the PATH -// it would export. This matches the behavior of every popular Electron app that -// handles this problem (Hyper, VS Code, Cursor, etc. via shell-env/fix-path) — -// we implement it inline to avoid adding a dependency. +// Probe the profile-loading shell once instead of hard-coding every tool's install path. const DELIMITER = '__ORCA_SHELL_PATH__' const SPAWN_TIMEOUT_MS = 5000 @@ -34,15 +30,35 @@ export type HydrationResult = } let cached: Promise | null = null +let probeQueue = Promise.resolve() +let configuredWindowsShell = 'powershell.exe' +let configuredWindowsGitBashPath: string | null = null +let configuredWindowsFallbackShell: string | null = null +let windowsShellConfigurationVersion = 0 +const windowsIntroducedPathKeys = new Set() /** @internal - tests need a clean hydration cache between cases. */ export function _resetHydrateShellPathCache(): void { cached = null + probeQueue = Promise.resolve() + configuredWindowsShell = 'powershell.exe' + configuredWindowsGitBashPath = null + configuredWindowsFallbackShell = null + windowsShellConfigurationVersion = 0 + windowsIntroducedPathKeys.clear() } function pickShell(): string | null { if (process.platform === 'win32') { - return null + const family = resolveWindowsShellStartupFamily(configuredWindowsShell) + if (family === 'cmd') { + return null + } + if (family === 'posix') { + return configuredWindowsGitBashPath + } + const basename = pathWin32.basename(configuredWindowsShell).toLowerCase() + return basename === 'powershell.exe' || basename === 'pwsh.exe' ? configuredWindowsShell : null } const shell = process.env.SHELL if (shell && shell.length > 0) { @@ -51,7 +67,7 @@ function pickShell(): string | null { return process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash' } -function parseCapturedPath(stdout: string): string[] { +function parseCapturedPath(stdout: string, pathDelimiter: string = delimiter): string[] { const cleaned = stdout.replace(ANSI_RE, '') const first = cleaned.indexOf(DELIMITER) if (first < 0) { @@ -70,32 +86,48 @@ function parseCapturedPath(stdout: string): string[] { return [ ...new Set( value - .split(delimiter) + .split(pathDelimiter) .map((s) => s.trim()) .filter(Boolean) ) ] } +function shellPathProbe(shell: string): { args: string[]; pathDelimiter: string } { + if (process.platform !== 'win32') { + const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'` + return { args: ['-ilc', command], pathDelimiter: delimiter } + } + if (resolveWindowsShellStartupFamily(shell) === 'posix') { + // Why: native child processes cannot resolve Git Bash's /c/... PATH entries. + const command = `printf '%s' '${DELIMITER}'; cygpath -wp "$PATH"; printf '%s' '${DELIMITER}'` + return { args: ['-ilc', command], pathDelimiter: ';' } + } + const command = + `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ` + + `[Console]::Write('${DELIMITER}'); [Console]::Write($env:Path); ` + + `[Console]::Write('${DELIMITER}')` + // Why: omitting -NoProfile is the behavior this probe exists to capture. + return { args: ['-NoLogo', '-Command', command], pathDelimiter: ';' } +} + function spawnShellAndReadPath(shell: string): Promise { return new Promise((resolve) => { - // Why: printing $PATH between delimiters is resilient to rc-file banners, - // MOTDs, and `echo` invocations that shells like fish print unprompted. - // `-ilc` runs the shell as a login+interactive so both .profile/.zprofile - // and .bashrc/.zshrc are sourced — matches what `which` in Terminal sees. - const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'` + // Why: delimiters isolate PATH from profile banners and MOTDs. + const probe = shellPathProbe(shell) let finished = false let stdout = '' let timer: ReturnType | null = null - const child = spawn(shell, ['-ilc', command], { + const child = spawn(shell, probe.args, { // Why: inherit current env so the shell sees the same baseline, then let // it layer its own rc files on top. Do NOT forward stdio — some shells // (oh-my-zsh setups, powerlevel10k) print a lot to stderr on startup, // and we don't want that in Orca's console. env: process.env, stdio: ['ignore', 'pipe', 'ignore'], - detached: false + detached: false, + windowsHide: true }) const cleanup = (): void => { @@ -137,7 +169,7 @@ function spawnShellAndReadPath(shell: string): Promise { } const onClose = (): void => { - const segments = parseCapturedPath(stdout) + const segments = parseCapturedPath(stdout, probe.pathDelimiter) if (segments.length === 0) { finish({ segments: [], ok: false, failureReason: 'empty_path' }) return @@ -160,7 +192,7 @@ type HydrateOptions = { } /** - * Spawn the user's login shell once and return the PATH it would export. + * Spawn the user's profile-loading shell once and return the PATH it would export. * Caches the promise for the lifetime of the process — call * `_resetHydrateShellPathCache()` in tests or `hydrateShellPath({ force: true })` * when the user asks to re-probe (e.g. after installing a new CLI). @@ -169,17 +201,92 @@ export function hydrateShellPath(options: HydrateOptions = {}): Promise { + const result = await spawner(shell) + if (!result.ok && result.failureReason === 'spawn_error' && fallbackShell) { + return spawner(fallbackShell) + } + return result + }) + // Why: one rejected profile must not block later refreshes or shell changes. + probeQueue = probe.then( + () => undefined, + () => undefined + ) + cached = probe.then((result) => { + if ( + platform === 'win32' && + options.shellOverride === undefined && + configurationVersion !== windowsShellConfigurationVersion + ) { + return hydrateShellPath() + } + return result + }) return cached } +export function configureWindowsShellPathHydration( + shell: string | null | undefined, + gitBashPath: string | null = null, + fallbackShell: string | null = null +): void { + const next = shell?.trim() || 'powershell.exe' + if ( + next === configuredWindowsShell && + gitBashPath === configuredWindowsGitBashPath && + fallbackShell === configuredWindowsFallbackShell + ) { + return + } + clearWindowsIntroducedPathSegments() + configuredWindowsShell = next + configuredWindowsGitBashPath = gitBashPath + configuredWindowsFallbackShell = fallbackShell + windowsShellConfigurationVersion += 1 + cached = null +} + +function uniquePathSegments(segments: string[], pathKey: (segment: string) => string): string[] { + const seen = new Set() + return segments.filter((segment) => { + const key = pathKey(segment) + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + +function windowsPathKey(segment: string): string { + const normalized = pathWin32.normalize(segment) + const root = pathWin32.parse(normalized).root + const withoutTrailingSlash = + normalized.length > root.length ? normalized.replace(/[\\/]+$/, '') : normalized + return withoutTrailingSlash.toLowerCase() +} + +function clearWindowsIntroducedPathSegments(): void { + if (process.platform !== 'win32' || windowsIntroducedPathKeys.size === 0) { + return + } + const currentSegments = (process.env.PATH ?? '').split(pathWin32.delimiter).filter(Boolean) + process.env.PATH = currentSegments + .filter((segment) => !windowsIntroducedPathKeys.has(windowsPathKey(segment))) + .join(pathWin32.delimiter) + windowsIntroducedPathKeys.clear() +} + /** * Promote shell-discovered PATH segments to the front of process.env.PATH, * preserving shell ordering and avoiding duplicates. Returns the segments that @@ -190,16 +297,19 @@ export function mergePathSegments(segments: string[]): string[] { return [] } const current = process.env.PATH ?? '' - const currentSegments = current.split(delimiter).filter(Boolean) - const shellSegments = [...new Set(segments)] - const shellSegmentSet = new Set(shellSegments) - const existing = new Set(currentSegments) - const added = shellSegments.filter((segment) => !existing.has(segment)) + const pathDelimiter = process.platform === 'win32' ? pathWin32.delimiter : delimiter + const currentSegments = current.split(pathDelimiter).filter(Boolean) + const pathKey = + process.platform === 'win32' ? windowsPathKey : (segment: string): string => segment + const shellSegments = uniquePathSegments(segments, pathKey) + const shellSegmentSet = new Set(shellSegments.map(pathKey)) + const existing = new Set(currentSegments.map(pathKey)) + const added = shellSegments.filter((segment) => !existing.has(pathKey(segment))) const merged = [ ...shellSegments, - ...currentSegments.filter((segment) => !shellSegmentSet.has(segment)) + ...currentSegments.filter((segment) => !shellSegmentSet.has(pathKey(segment))) ] - const next = merged.join(delimiter) + const next = merged.join(pathDelimiter) if (next === current) { return [] } @@ -207,5 +317,10 @@ export function mergePathSegments(segments: string[]): string[] { // A seeded fallback can point at a stale CLI while the user's shell resolves // a healthy one from the same directory list in a different order. process.env.PATH = next + if (process.platform === 'win32') { + for (const segment of added) { + windowsIntroducedPathKeys.add(windowsPathKey(segment)) + } + } return added } diff --git a/src/main/startup/hydrate-shell-path.windows.test.ts b/src/main/startup/hydrate-shell-path.windows.test.ts new file mode 100644 index 00000000000..ee1957c8a42 --- /dev/null +++ b/src/main/startup/hydrate-shell-path.windows.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import type { ChildProcessWithoutNullStreams } from 'node:child_process' +import { + _resetHydrateShellPathCache, + configureWindowsShellPathHydration, + hydrateShellPath, + mergePathSegments, + type HydrationResult +} from './hydrate-shell-path' + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + spawn: spawnMock +})) + +type HydrationSpawner = (shell: string) => Promise + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function createMockShellProcess(): ChildProcessWithoutNullStreams { + const proc = new EventEmitter() as ChildProcessWithoutNullStreams + Object.assign(proc, { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + stdin: new EventEmitter(), + kill: vi.fn() + }) + return proc +} + +describe('Windows shell PATH hydration', () => { + const originalPath = process.env.PATH + + beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + _resetHydrateShellPathCache() + spawnMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + }) + + it('hydrates the default PowerShell profile PATH on Windows (#13328)', async () => { + const spawner = vi.fn(async () => ({ + segments: ['C:\\Users\\tester\\AppData\\Local\\fnm'], + ok: true, + failureReason: 'none' + })) + + await expect(hydrateShellPath({ spawner })).resolves.toEqual({ + segments: ['C:\\Users\\tester\\AppData\\Local\\fnm'], + ok: true, + failureReason: 'none' + }) + expect(spawner).toHaveBeenCalledWith('powershell.exe') + }) + + it('uses the configured PowerShell executable', async () => { + const spawner = vi.fn(async () => ({ + segments: ['C:\\tools'], + ok: true, + failureReason: 'none' + })) + + configureWindowsShellPathHydration('C:\\Program Files\\PowerShell\\7\\pwsh.exe') + await hydrateShellPath({ spawner }) + + expect(spawner).toHaveBeenCalledWith('C:\\Program Files\\PowerShell\\7\\pwsh.exe') + }) + + it('falls back to Windows PowerShell when preferred pwsh cannot spawn', async () => { + const spawner = vi.fn() + spawner + .mockResolvedValueOnce({ segments: [], ok: false, failureReason: 'spawn_error' }) + .mockResolvedValueOnce({ + segments: ['C:\\WindowsPowerShell-profile'], + ok: true, + failureReason: 'none' + }) + configureWindowsShellPathHydration('pwsh.exe', null, 'powershell.exe') + + await expect(hydrateShellPath({ spawner })).resolves.toEqual({ + segments: ['C:\\WindowsPowerShell-profile'], + ok: true, + failureReason: 'none' + }) + expect(spawner.mock.calls).toEqual([['pwsh.exe'], ['powershell.exe']]) + }) + + it('uses the resolved Git Bash executable for the configured sentinel', async () => { + const spawner = vi.fn(async () => ({ + segments: ['C:\\tools'], + ok: true, + failureReason: 'none' + })) + + configureWindowsShellPathHydration('git-bash', 'C:\\Program Files\\Git\\bin\\bash.exe') + await hydrateShellPath({ spawner }) + + expect(spawner).toHaveBeenCalledWith('C:\\Program Files\\Git\\bin\\bash.exe') + }) + + it('loads PowerShell profiles and parses their Windows PATH', async () => { + const proc = createMockShellProcess() + spawnMock.mockReturnValue(proc) + const resultPromise = hydrateShellPath({ force: true }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) + + proc.stdout.emit( + 'data', + Buffer.from( + 'profile banner\r\n__ORCA_SHELL_PATH__C:\\profile-node;C:\\Windows__ORCA_SHELL_PATH__' + ) + ) + proc.emit('close', 0, null) + + await expect(resultPromise).resolves.toEqual({ + segments: ['C:\\profile-node', 'C:\\Windows'], + ok: true, + failureReason: 'none' + }) + expect(spawnMock).toHaveBeenCalledWith( + 'powershell.exe', + ['-NoLogo', '-Command', expect.stringContaining('$env:Path')], + expect.objectContaining({ windowsHide: true }) + ) + expect(spawnMock.mock.calls[0]?.[1]).not.toContain('-NoProfile') + }) + + it('converts a Git Bash PATH to Windows segments before parsing it', async () => { + const proc = createMockShellProcess() + spawnMock.mockReturnValue(proc) + const resultPromise = hydrateShellPath({ + shellOverride: 'C:\\Program Files\\Git\\bin\\bash.exe', + force: true + }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) + + proc.stdout.emit( + 'data', + Buffer.from('__ORCA_SHELL_PATH__C:\\git-node;C:\\Windows__ORCA_SHELL_PATH__') + ) + proc.emit('close', 0, null) + + await expect(resultPromise).resolves.toEqual({ + segments: ['C:\\git-node', 'C:\\Windows'], + ok: true, + failureReason: 'none' + }) + expect(spawnMock).toHaveBeenCalledWith( + 'C:\\Program Files\\Git\\bin\\bash.exe', + ['-ilc', expect.stringContaining('cygpath -wp "$PATH"')], + expect.objectContaining({ windowsHide: true }) + ) + }) + + it.each(['cmd.exe', 'wsl.exe', 'git-bash', 'C:\\Tools\\zsh.exe'])( + 'does not merge %s guest or profile-less paths', + async (shell) => { + const spawner = vi.fn() + + configureWindowsShellPathHydration(shell) + + await expect(hydrateShellPath({ spawner })).resolves.toEqual({ + segments: [], + ok: false, + failureReason: 'no_shell' + }) + expect(spawner).not.toHaveBeenCalled() + } + ) + + it('serializes shell changes, discards stale results, and caches the newest request', async () => { + const powerShellResult = deferred() + const gitBashResult = deferred() + let activeProbes = 0 + let maxActiveProbes = 0 + const powerShellSpawner = vi.fn(async () => { + activeProbes += 1 + maxActiveProbes = Math.max(maxActiveProbes, activeProbes) + const result = await powerShellResult.promise + activeProbes -= 1 + return result + }) + const gitBashSpawner = vi.fn(async () => { + activeProbes += 1 + maxActiveProbes = Math.max(maxActiveProbes, activeProbes) + const result = await gitBashResult.promise + activeProbes -= 1 + return result + }) + process.env.PATH = 'C:\\Windows' + const powerShellReady = hydrateShellPath({ spawner: powerShellSpawner, force: true }) + const powerShellMerged = powerShellReady.then((result) => mergePathSegments(result.segments)) + await vi.waitFor(() => expect(powerShellSpawner).toHaveBeenCalledWith('powershell.exe')) + + configureWindowsShellPathHydration('git-bash', 'C:\\Git\\bin\\bash.exe') + const gitBashReady = hydrateShellPath({ spawner: gitBashSpawner, force: true }) + const gitBashMerged = gitBashReady.then((result) => mergePathSegments(result.segments)) + const cachedGitBashReady = hydrateShellPath({ spawner: gitBashSpawner }) + expect(cachedGitBashReady).toBe(gitBashReady) + expect(gitBashSpawner).not.toHaveBeenCalled() + + powerShellResult.resolve({ + segments: ['C:\\stale-powershell'], + ok: true, + failureReason: 'none' + }) + await vi.waitFor(() => expect(gitBashSpawner).toHaveBeenCalledWith('C:\\Git\\bin\\bash.exe')) + gitBashResult.resolve({ + segments: ['C:\\current-git-bash'], + ok: true, + failureReason: 'none' + }) + await Promise.all([powerShellMerged, gitBashMerged, cachedGitBashReady]) + + expect(maxActiveProbes).toBe(1) + expect(powerShellSpawner).toHaveBeenCalledOnce() + expect(gitBashSpawner).toHaveBeenCalledOnce() + expect(process.env.PATH).toBe('C:\\current-git-bash;C:\\Windows') + }) + + it('discards an active probe when the configured shell has no profile', async () => { + const powerShellResult = deferred() + const spawner = vi.fn(() => powerShellResult.promise) + const staleReady = hydrateShellPath({ spawner, force: true }) + await vi.waitFor(() => expect(spawner).toHaveBeenCalledOnce()) + + configureWindowsShellPathHydration('cmd.exe') + const currentReady = hydrateShellPath({ spawner }) + powerShellResult.resolve({ + segments: ['C:\\stale-powershell'], + ok: true, + failureReason: 'none' + }) + + await expect(Promise.all([staleReady, currentReady])).resolves.toEqual([ + { segments: [], ok: false, failureReason: 'no_shell' }, + { segments: [], ok: false, failureReason: 'no_shell' } + ]) + expect(spawner).toHaveBeenCalledOnce() + }) + + it('deduplicates PATH entries case-insensitively', () => { + process.env.PATH = 'C:\\Tools;C:\\Windows' + + expect(mergePathSegments(['c:\\tools', 'C:\\profile-node'])).toEqual(['C:\\profile-node']) + expect(process.env.PATH).toBe('c:\\tools;C:\\profile-node;C:\\Windows') + }) + + it('deduplicates PATH entries with trailing slashes', () => { + process.env.PATH = 'C:\\Windows\\;C:\\Tools' + + expect(mergePathSegments(['C:\\Windows', 'C:\\profile-node\\'])).toEqual(['C:\\profile-node\\']) + expect(process.env.PATH).toBe('C:\\Windows;C:\\profile-node\\;C:\\Tools') + }) + + it('removes profile entries when switching to a shell without a profile', () => { + process.env.PATH = 'C:\\Inherited;C:\\Windows' + mergePathSegments(['C:\\PowerShell-profile', 'C:\\Inherited']) + + configureWindowsShellPathHydration('cmd.exe') + + expect(process.env.PATH).toBe('C:\\Inherited;C:\\Windows') + }) + + it('replaces profile entries introduced by the previously configured shell', () => { + process.env.PATH = 'C:\\Inherited;C:\\Windows' + mergePathSegments(['C:\\PowerShell-profile', 'C:\\Inherited']) + + configureWindowsShellPathHydration('git-bash', 'C:\\Git\\bin\\bash.exe') + mergePathSegments(['C:\\GitBash-profile', 'C:\\Inherited']) + + expect(process.env.PATH).toBe('C:\\GitBash-profile;C:\\Inherited;C:\\Windows') + }) +}) diff --git a/src/main/startup/windows-shell-path-hydration.test.ts b/src/main/startup/windows-shell-path-hydration.test.ts new file mode 100644 index 00000000000..87175008bfd --- /dev/null +++ b/src/main/startup/windows-shell-path-hydration.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from 'vitest' +import { createWindowsShellPathHydration } from './windows-shell-path-hydration' +import type { HydrationResult } from './hydrate-shell-path' + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function success(segment: string): HydrationResult { + return { segments: [segment], ok: true, failureReason: 'none' } +} + +describe('Windows shell PATH hydration coordination', () => { + it('serializes probes and never merges a superseded shell result', async () => { + const first = deferred() + const second = deferred() + const hydrate = vi.fn<() => Promise>() + hydrate.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const merge = vi.fn<(segments: string[]) => string[]>(() => []) + const configure = vi.fn() + const coordinator = createWindowsShellPathHydration({ + configure, + hydrate, + merge, + resolveGitBashPath: (shell) => (shell === 'git-bash' ? 'C:\\Git\\bin\\bash.exe' : null) + }) + + const powerShellReady = coordinator.hydrate('powershell.exe') + await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(1)) + const gitBashReady = coordinator.hydrate('git-bash') + + first.resolve(success('C:\\stale-powershell')) + await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(2)) + expect(merge).not.toHaveBeenCalled() + + second.resolve(success('C:\\current-git-bash')) + await Promise.all([powerShellReady, gitBashReady]) + + expect(merge).toHaveBeenCalledOnce() + expect(merge).toHaveBeenCalledWith(['C:\\current-git-bash']) + expect(configure).toHaveBeenLastCalledWith('git-bash', 'C:\\Git\\bin\\bash.exe', null) + }) + + it('coalesces queued shell changes to the latest configured shell', async () => { + const active = deferred() + const latest = deferred() + const hydrate = vi.fn<() => Promise>() + hydrate.mockReturnValueOnce(active.promise).mockReturnValueOnce(latest.promise) + const merge = vi.fn<(segments: string[]) => string[]>(() => []) + const coordinator = createWindowsShellPathHydration({ hydrate, merge }) + + const activeReady = coordinator.hydrate('powershell.exe') + await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(1)) + const skippedReady = coordinator.hydrate('cmd.exe') + const latestReady = coordinator.hydrate('powershell.exe') + active.resolve(success('C:\\stale')) + await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(2)) + latest.resolve(success('C:\\latest')) + + await Promise.all([activeReady, skippedReady, latestReady]) + expect(hydrate).toHaveBeenCalledTimes(2) + expect(merge).toHaveBeenCalledOnce() + expect(merge).toHaveBeenCalledWith(['C:\\latest']) + }) + + it('keeps the startup barrier behind shell changes queued while it waits', async () => { + const first = deferred() + const second = deferred() + const hydrate = vi.fn<() => Promise>() + hydrate.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const coordinator = createWindowsShellPathHydration({ hydrate }) + void coordinator.hydrate('powershell.exe', 'powershell.exe') + await vi.waitFor(() => expect(hydrate).toHaveBeenCalledOnce()) + let startupReady = false + const startupBarrier = coordinator.whenReady().then(() => { + startupReady = true + }) + + void coordinator.hydrate('git-bash') + first.resolve(success('C:\\stale')) + await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(2)) + expect(startupReady).toBe(false) + + second.resolve(success('C:\\latest')) + await startupBarrier + expect(startupReady).toBe(true) + }) + + it('uses the terminal safety chain for a custom PowerShell path', () => { + const configure = vi.fn() + const coordinator = createWindowsShellPathHydration({ + configure, + resolvePowerShellChain: () => [ + 'C:\\Resolved\\pwsh.exe', + 'C:\\Resolved\\powershell.exe', + 'C:\\Resolved\\cmd.exe' + ] + }) + + coordinator.configure('C:\\Program Files\\PowerShell\\7\\pwsh.exe', 'pwsh.exe') + + expect(configure).toHaveBeenCalledWith( + 'C:\\Resolved\\pwsh.exe', + null, + 'C:\\Resolved\\powershell.exe' + ) + }) + + it('configures refresh callers without spawning in development', () => { + const configure = vi.fn() + const hydrate = vi.fn<() => Promise>() + const coordinator = createWindowsShellPathHydration({ + configure, + hydrate, + resolveGitBashPath: () => 'C:\\Git\\bin\\bash.exe' + }) + + coordinator.configure('git-bash') + + expect(configure).toHaveBeenCalledWith('git-bash', 'C:\\Git\\bin\\bash.exe', null) + expect(hydrate).not.toHaveBeenCalled() + }) + + it.each([ + { + implementation: 'auto' as const, + expectedShell: 'pwsh.exe', + expectedFallback: 'powershell.exe' + }, + { + implementation: 'pwsh.exe' as const, + expectedShell: 'pwsh.exe', + expectedFallback: 'powershell.exe' + }, + { + implementation: 'powershell.exe' as const, + expectedShell: 'powershell.exe', + expectedFallback: null + } + ])( + 'configures $implementation to hydrate the effective PowerShell profile', + ({ implementation, expectedShell, expectedFallback }) => { + const configure = vi.fn() + const coordinator = createWindowsShellPathHydration({ + configure, + resolvePowerShellChain: (family) => + family === 'pwsh.exe' + ? ['pwsh.exe', 'powershell.exe', 'cmd.exe'] + : ['powershell.exe', 'cmd.exe'] + }) + + coordinator.configure('powershell.exe', implementation) + + expect(configure).toHaveBeenCalledWith(expectedShell, null, expectedFallback) + } + ) + + it('reconfigures when the PowerShell implementation changes', () => { + const configure = vi.fn() + const coordinator = createWindowsShellPathHydration({ + configure, + resolvePowerShellChain: (family) => + family === 'pwsh.exe' + ? ['pwsh.exe', 'powershell.exe', 'cmd.exe'] + : ['powershell.exe', 'cmd.exe'] + }) + + coordinator.configure('powershell.exe', 'powershell.exe') + coordinator.configure('powershell.exe', 'pwsh.exe') + + expect(configure).toHaveBeenNthCalledWith(1, 'powershell.exe', null, null) + expect(configure).toHaveBeenNthCalledWith(2, 'pwsh.exe', null, 'powershell.exe') + }) + + it('uses inbox PowerShell when pwsh has no safe executable behind its Store alias', () => { + const configure = vi.fn() + const coordinator = createWindowsShellPathHydration({ + configure, + resolvePowerShellChain: () => [ + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + 'C:\\Windows\\System32\\cmd.exe' + ] + }) + + coordinator.configure('powershell.exe', 'auto') + + expect(configure).toHaveBeenCalledWith( + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + null, + null + ) + }) + + it('skips hydration when no PowerShell executable resolves safely', () => { + const configure = vi.fn() + const coordinator = createWindowsShellPathHydration({ + configure, + resolvePowerShellChain: () => ['C:\\Windows\\System32\\cmd.exe'] + }) + + coordinator.configure('powershell.exe', 'auto') + + expect(configure).toHaveBeenCalledWith('C:\\Windows\\System32\\cmd.exe', null, null) + }) +}) diff --git a/src/main/startup/windows-shell-path-hydration.ts b/src/main/startup/windows-shell-path-hydration.ts new file mode 100644 index 00000000000..083ea8dbe57 --- /dev/null +++ b/src/main/startup/windows-shell-path-hydration.ts @@ -0,0 +1,107 @@ +import { win32 as pathWin32 } from 'node:path' +import { resolveWindowsGitBashShellPath } from '../git-bash' +import { + resolveEffectiveWindowsPowerShell, + type WindowsPowerShellImplementation, + type WindowsPowerShellShellFamily +} from '../providers/windows-powershell' +import { resolveWindowsPowerShellSpawnChain } from '../providers/windows-powershell-executable' +import { + configureWindowsShellPathHydration, + hydrateShellPath, + mergePathSegments, + type HydrationResult +} from './hydrate-shell-path' + +type WindowsShellPathHydrationOptions = { + configure?: ( + shell: string | null | undefined, + gitBashPath: string | null, + fallbackShell: string | null + ) => void + hydrate?: () => Promise + merge?: (segments: string[]) => string[] + resolveGitBashPath?: (shell: string) => string | null + resolvePowerShellChain?: (family: 'powershell.exe' | 'pwsh.exe') => string[] + warn?: (error: unknown) => void +} + +export function createWindowsShellPathHydration(options: WindowsShellPathHydrationOptions = {}) { + const configure = options.configure ?? configureWindowsShellPathHydration + const hydrate = options.hydrate ?? hydrateShellPath + const merge = options.merge ?? mergePathSegments + const resolveGitBashPath = options.resolveGitBashPath ?? resolveWindowsGitBashShellPath + const resolvePowerShellChain = + options.resolvePowerShellChain ?? resolveWindowsPowerShellSpawnChain + const warn = + options.warn ?? + ((error: unknown) => { + console.warn('[shell-path] Windows profile hydration failed; using inherited PATH:', error) + }) + let generation = 0 + let ready = Promise.resolve() + + const setConfiguredShell = ( + shell: string | null | undefined, + implementation?: WindowsPowerShellImplementation + ): void => { + const requestedShell = shell?.trim() || 'powershell.exe' + const basename = pathWin32.basename(requestedShell).toLowerCase() + const shellFamily: WindowsPowerShellShellFamily = + basename === 'powershell.exe' || basename === 'pwsh.exe' ? basename : undefined + const effectivePowerShell = resolveEffectiveWindowsPowerShell({ + shellFamily, + implementation, + pwshAvailable: true + }) + if (!effectivePowerShell) { + configure(requestedShell, resolveGitBashPath(requestedShell), null) + return + } + const spawnChain = resolvePowerShellChain(effectivePowerShell) + const profileShell = spawnChain[0] ?? 'cmd.exe' + const fallbackShell = spawnChain.slice(1).find((candidate) => { + const candidateBasename = pathWin32.basename(candidate).toLowerCase() + return candidateBasename === 'powershell.exe' || candidateBasename === 'pwsh.exe' + }) + configure(profileShell, null, fallbackShell ?? null) + } + + return { + configure: (shell, implementation?: WindowsPowerShellImplementation) => { + generation += 1 + setConfiguredShell(shell, implementation) + }, + hydrate: (shell, implementation?: WindowsPowerShellImplementation) => { + generation += 1 + const requestGeneration = generation + setConfiguredShell(shell, implementation) + ready = ready.then(async () => { + if (requestGeneration !== generation) { + return + } + try { + const result = await hydrate() + if (requestGeneration === generation && result.ok) { + merge(result.segments) + } + } catch (error) { + if (requestGeneration === generation) { + warn(error) + } + } + }) + return ready + }, + whenReady: async () => { + let pending = ready + while (true) { + await pending + if (pending === ready) { + return + } + pending = ready + } + } + } +} From 8a38e2f3f8d91a3d884d1ae54ab17aae5d466bdf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:50:56 -0700 Subject: [PATCH 011/271] refactor(renderer): reuse existing control paths (#13425) --- .../components/browser-pane/useGrabMode.ts | 39 ++++++------------- src/renderer/src/lib/setup-script-prompt.ts | 20 +--------- 2 files changed, 12 insertions(+), 47 deletions(-) diff --git a/src/renderer/src/components/browser-pane/useGrabMode.ts b/src/renderer/src/components/browser-pane/useGrabMode.ts index b3ee2580572..018fb0368fe 100644 --- a/src/renderer/src/components/browser-pane/useGrabMode.ts +++ b/src/renderer/src/components/browser-pane/useGrabMode.ts @@ -183,34 +183,6 @@ export function useGrabMode(browserPageId: string): GrabModeHook { } }, [mountedRef]) - const toggle = useCallback(() => { - if ((state === 'idle' || state === 'error') && grabTabIdRef.current === null) { - setError(null) - setPayload(null) - setContextMenu(false) - void armAndAwait() - } else { - // Disable grab mode - const targetTabId = grabTabIdRef.current ?? browserTabIdRef.current - armGenerationRef.current += 1 - void window.api.browser.setGrabMode({ - browserPageId: targetTabId, - enabled: false - }) - if (activeOpIdRef.current) { - void window.api.browser.cancelGrab({ - browserPageId: targetTabId - }) - activeOpIdRef.current = null - } - grabTabIdRef.current = null - setState('idle') - setPayload(null) - setError(null) - setContextMenu(false) - } - }, [state, armAndAwait]) - const cancel = useCallback(() => { const targetTabId = grabTabIdRef.current ?? browserTabIdRef.current armGenerationRef.current += 1 @@ -231,6 +203,17 @@ export function useGrabMode(browserPageId: string): GrabModeHook { setContextMenu(false) }, []) + const toggle = useCallback(() => { + if ((state === 'idle' || state === 'error') && grabTabIdRef.current === null) { + setError(null) + setPayload(null) + setContextMenu(false) + void armAndAwait() + } else { + cancel() + } + }, [state, armAndAwait, cancel]) + // Why: Copy re-arms so the user can quickly pick another element without // re-clicking the toolbar button. Attach to AI exits because the user's // intent is to continue in the chat, not keep selecting. diff --git a/src/renderer/src/lib/setup-script-prompt.ts b/src/renderer/src/lib/setup-script-prompt.ts index d85a3a9ec30..f25b806d1ef 100644 --- a/src/renderer/src/lib/setup-script-prompt.ts +++ b/src/renderer/src/lib/setup-script-prompt.ts @@ -4,6 +4,7 @@ import type { SetupScriptImportCandidate } from '../../../shared/setup-script-im import type { Repo, RepoHookSettings } from '../../../shared/types' import type { HookCheckResult } from '@/runtime/runtime-hooks-client' import { isRuntimeScopeForbiddenError } from '@/runtime/runtime-rpc-client' +import { hasEffectiveSetupCommand } from './setup-script-status' const SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX = 'generation-v1:' @@ -69,25 +70,6 @@ export async function inspectSetupScriptPromptState({ } } -export function hasEffectiveSetupCommand(repo: Repo, hooksResult: HookCheckResult): boolean { - const localSetup = repo.hookSettings?.scripts?.setup?.trim() - const sharedSetup = hooksResult.hooks?.scripts?.setup?.trim() - const rawPolicy = repo.hookSettings?.commandSourcePolicy - const sourcePolicy = resolveHookCommandSourcePolicy(rawPolicy, { - hasLocalScript: Boolean(localSetup) - }) - - if (sourcePolicy === 'local-only') { - return Boolean(localSetup) - } - - if (sourcePolicy === 'run-both') { - return Boolean(sharedSetup || localSetup) - } - - return Boolean(sharedSetup) -} - export function ignoresSharedSetupScripts(repo: Pick): boolean { const localSetup = repo.hookSettings?.scripts?.setup?.trim() return ( From 934faaec7634b34bf4923de7e8e1574216c594ec Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:54:22 -0700 Subject: [PATCH 012/271] refactor: deduplicate Linear team readers (#13417) --- src/main/linear/teams.test.ts | 30 ++++++++- src/main/linear/teams.ts | 118 +++++++--------------------------- 2 files changed, 51 insertions(+), 97 deletions(-) diff --git a/src/main/linear/teams.test.ts b/src/main/linear/teams.test.ts index d04845e6f28..00d68988bab 100644 --- a/src/main/linear/teams.test.ts +++ b/src/main/linear/teams.test.ts @@ -5,10 +5,12 @@ import { credentialDecryptionMessage } from '../../shared/integration-credential const getClients = vi.fn() const clearToken = vi.fn() const isAuthError = vi.fn() +const acquire = vi.fn().mockResolvedValue(undefined) +const release = vi.fn() vi.mock('./client', () => ({ - acquire: vi.fn().mockResolvedValue(undefined), - release: vi.fn(), + acquire, + release, getClients: (...args: unknown[]) => getClients(...args), isAuthError: (...args: unknown[]) => isAuthError(...args), clearToken: (...args: unknown[]) => clearToken(...args) @@ -283,6 +285,30 @@ describe('Linear teams', () => { expect(members).toHaveBeenCalledWith({ first: 100 }) }) + it.each([ + ['getTeamStates', 'getTeamStatesOrThrow'], + ['getTeamLabels', 'getTeamLabelsOrThrow'], + ['getTeamMembers', 'getTeamMembersOrThrow'] + ] as const)('preserves the %s error policy', async (fallbackReader, throwingReader) => { + const error = new Error('request failed') + const entry = makeTeamLookupEntry('workspace-1', 'Workspace', null) + vi.mocked(entry.client.team).mockRejectedValue(error) + getClients.mockReturnValue([entry]) + const readers = await import('./teams') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(readers[fallbackReader]('team-1', 'workspace-1')).resolves.toEqual([]) + await expect(readers[throwingReader]('team-1', 'workspace-1')).rejects.toBe(error) + expect(warn).toHaveBeenCalledWith(`[linear] ${fallbackReader} failed:`, error) + expect(release).toHaveBeenCalledTimes(2) + + isAuthError.mockReturnValue(true) + await expect(readers[fallbackReader]('team-1', 'workspace-1')).rejects.toBe(error) + expect(clearToken).toHaveBeenCalledWith('workspace-1') + expect(release).toHaveBeenCalledTimes(3) + warn.mockRestore() + }) + it('surfaces Linear credential decrypt errors on active team reads', async () => { const error = new Error(credentialDecryptionMessage('Linear')) getClients.mockImplementation(() => { diff --git a/src/main/linear/teams.ts b/src/main/linear/teams.ts index e06697e8d46..004c8fbd92a 100644 --- a/src/main/linear/teams.ts +++ b/src/main/linear/teams.ts @@ -1,3 +1,4 @@ +import type { Team } from '@linear/sdk' import type { LinearTeam, LinearWorkflowState, @@ -108,10 +109,12 @@ export async function listTeamsForAgent( } } -export async function getTeamStates( +async function readTeamResource( teamId: string, - workspaceId?: string | null -): Promise { + workspaceId: string | null | undefined, + fetchAll: (team: Team) => Promise, + fallbackWarning?: string +): Promise { const entry = getClients(workspaceId)[0] if (!entry) { return [] @@ -119,137 +122,62 @@ export async function getTeamStates( await acquire() try { - const team = await entry.client.team(teamId) - return await fetchAllTeamStates(team) + return await fetchAll(await entry.client.team(teamId)) } catch (error) { if (isAuthError(error)) { clearToken(entry.workspace.id) throw error } - console.warn('[linear] getTeamStates failed:', error) + if (!fallbackWarning) { + throw error + } + console.warn(`[linear] ${fallbackWarning} failed:`, error) return [] } finally { release() } } +export async function getTeamStates( + teamId: string, + workspaceId?: string | null +): Promise { + return readTeamResource(teamId, workspaceId, fetchAllTeamStates, 'getTeamStates') +} + export async function getTeamStatesOrThrow( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamStates(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamStates) } export async function getTeamLabels( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamLabels(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - throw error - } - console.warn('[linear] getTeamLabels failed:', error) - return [] - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamLabels, 'getTeamLabels') } export async function getTeamLabelsOrThrow( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamLabels(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamLabels) } export async function getTeamMembers( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamMembers(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - throw error - } - console.warn('[linear] getTeamMembers failed:', error) - return [] - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamMembers, 'getTeamMembers') } export async function getTeamMembersOrThrow( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamMembers(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamMembers) } export async function getViewerForWorkspaceOrThrow( From 403c60bbadb734d870a22c372aca8a988e348d21 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:55:36 -0700 Subject: [PATCH 013/271] refactor(mobile): remove unused review code (#13421) --- mobile/src/source-control/mobile-pr-create.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/mobile/src/source-control/mobile-pr-create.ts b/mobile/src/source-control/mobile-pr-create.ts index 20f908d3df2..65a70a63ed9 100644 --- a/mobile/src/source-control/mobile-pr-create.ts +++ b/mobile/src/source-control/mobile-pr-create.ts @@ -2,7 +2,6 @@ import { hostedReviewCopy } from './hosted-review-copy' import { buildMobileHostedReviewCreateParams, createMobileHostedReview, - fetchMobileHostedReviewEligibility, mobileRepoSelectorFromWorktreeId, resolveMobileHostedReviewPrefill, shouldPushBeforeMobileHostedReviewCreate, @@ -20,7 +19,6 @@ export type MobilePrCreateOutcome = MobileHostedReviewCreateOutcome export { buildMobileHostedReviewCreateParams as buildMobilePrCreateParams, createMobileHostedReview as createMobilePr, - fetchMobileHostedReviewEligibility as fetchMobilePrEligibility, mobileRepoSelectorFromWorktreeId, resolveMobileHostedReviewPrefill as resolveMobilePrPrefill, shouldPushBeforeMobileHostedReviewCreate as shouldPushBeforeMobilePrCreate From 3b1017c4fbd9a3e2cbac66feac35d5c6b5342da2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:01:35 -0700 Subject: [PATCH 014/271] Add nightly cut (#13410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add daily macOS dev build release channel Publish once-daily signed macOS builds from main at a dedicated cadence, separate from hourly (too noisy) and release branches (too infrequent). Builds are notarized and installable via the updater, but unvetted — published to stablyai/orca-daily rather than the main repo to avoid evicting stable/RC entries from the releases feed. * fix lint * fix commit * Add third token mint to daily macOS build workflow The upload step's 2x45m retry budget can outlive the one-hour token, so a third is minted after it for verify and cleanup operations. Release notes are moved to a file to ensure consistency between draft creation and publish. Daily channel description updated with specific UTC release time. --- .github/workflows/daily-mac-build.yml | 471 ++++++++++++++++++ config/electron-builder.config.cjs | 25 +- config/scripts/daily-build-version.mjs | 109 ++++ config/scripts/daily-build-version.test.mjs | 111 +++++ .../scripts/electron-builder-config.test.mjs | 119 ----- ...ectron-builder-mac-channel-config.test.mjs | 152 ++++++ config/scripts/setup-adhoc-release-repo.sh | 2 +- config/scripts/setup-daily-release-repo.sh | 98 ++++ config/scripts/setup-hourly-release-token.sh | 7 +- src/main/updater-release-builds.test.ts | 28 ++ src/main/updater.test.ts | 1 + .../settings/ReleaseChannelSection.tsx | 35 +- src/renderer/src/i18n/locales/en.json | 1 + src/shared/release-channel.test.ts | 44 +- src/shared/release-channel.ts | 61 ++- 15 files changed, 1106 insertions(+), 158 deletions(-) create mode 100644 .github/workflows/daily-mac-build.yml create mode 100755 config/scripts/daily-build-version.mjs create mode 100644 config/scripts/daily-build-version.test.mjs create mode 100644 config/scripts/electron-builder-mac-channel-config.test.mjs create mode 100755 config/scripts/setup-daily-release-repo.sh diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml new file mode 100644 index 00000000000..5670c634d2d --- /dev/null +++ b/.github/workflows/daily-mac-build.yml @@ -0,0 +1,471 @@ +name: Daily macOS Dev Build + +# Why: gives developers a signed macOS build of main once a day that the in-app +# updater can install directly, without waiting for an RC cut — less noise than +# hourly. +# +# Schedule is a single UTC cron (GH Actions has no timezone-aware schedules). +# 14:15 UTC is early morning Pacific year-round (6:15am PST / 7:15am PDT). Minute +# 15 avoids stacking with hourly-mac-build, which fires at minute 0 every hour. +# +# Deliberately narrow scope: +# - macOS only. Other platforms keep using RC/stable. +# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI +# and release-cut remain the gates that matter. +# - Signed AND notarized, exactly like a release. The notary round trip is the +# one slow step kept: macOS anchors a notarized app's TCC grants on identifier +# + team rather than on its cdhash, so those grants survive an update. Without +# a ticket every daily reads as a new client and silently loses file access +# under Documents/Desktop/Downloads. +# +# Artifacts publish to stablyai/orca-daily, never to stablyai/orca: the main +# repo's releases atom feed exposes only its 10 newest entries, so high-volume +# dev tags would evict every stable/RC entry and break updates for real users. +# Separate from orca-hourly so someone riding main's hourlies never sees the +# sparse daily series mixed into that list. +# +# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the +# same GitHub App as hourly (installed on orca-daily with Contents: Read and +# write). Provision the App secrets with +# `bash config/scripts/setup-hourly-release-token.sh`, then grant the App access +# to orca-daily with `bash config/scripts/setup-daily-release-repo.sh`: +# HOURLY_RELEASE_APP_ID the App's numeric id (shared with hourly/adhoc) +# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key +# +# Installation tokens live one hour, which is why this mints three times. Install +# and build need no token at all, and notarization can hold the publish step for +# tens of minutes; minting again once the build is done starts the clock at the +# first call that actually uses it rather than burning a third of it on +# `pnpm install`. The upload step's own retry budget (2x45m) can outlive that +# second token, so a third is minted after it for verify/publish/prune/cleanup — +# without it a stuck notary run would strand a draft that cleanup gets a 401 on. +# The release stays an unpublished draft until the manifest check passes, so the +# worst case is still an invisible draft — and the build-number query counts +# drafts, so it holds its number and the next run does not reuse it. + +on: + schedule: + # Once a day, early morning Pacific. Single cron — no DST twin, no clock gate. + - cron: '15 14 * * *' + workflow_dispatch: + inputs: + force: + description: Build even if main has not moved since the last daily + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: daily-mac-build + cancel-in-progress: false + +env: + DAILY_REPO: stablyai/orca-daily + # Keep ~30 days so a regression can be bisected without holding a full year. + DAILY_RETAIN_COUNT: 30 + +jobs: + build-daily-mac: + if: github.repository == 'stablyai/orca' + runs-on: blacksmith-6vcpu-macos-15 + # Why 150: it must exceed the worst case the retry budgets below can produce + # (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or + # the job is killed mid-retry and no cleanup step runs at all. A typical run + # is far shorter — this is the notary queue's tail, not its median. + timeout-minutes: 150 + env: + NODE_OPTIONS: --max-old-space-size=4096 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + # Why: this job only reads stablyai/orca and never pushes; every write + # goes to the daily repo through a minted App token passed by env. + # Not persisting the checkout credential shrinks the blast radius if a + # build step is compromised (zizmor: artipacked). + persist-credentials: false + + - name: Mint daily repo token + id: app_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-daily + + # Why: main is often idle. Rebuilding an unchanged commit burns a runner + # hour and adds a redundant tag to the retention window. + - name: Check whether main moved since the last daily + id: freshness + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }} + run: | + set -euo pipefail + head_sha="$(git rev-parse HEAD)" + echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT" + if [[ "$FORCED" == "true" ]]; then + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "Forced dispatch; building $head_sha." + exit 0 + fi + # The previous daily records its source commit in the release body. + # Drafts are excluded: an unpublished leftover never shipped, so treating + # it as "the last build" would skip a build that never actually happened. + last_body="$(gh release list --repo "$DAILY_REPO" --limit 20 --json tagName,isDraft \ + --jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)" + if [[ -z "$last_body" ]]; then + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "No prior daily release found; building $head_sha." + exit 0 + fi + last_sha="$(gh release view "$last_body" --repo "$DAILY_REPO" --json body \ + --jq '.body | capture("commit `(?[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)" + if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then + echo "should_build=false" >>"$GITHUB_OUTPUT" + echo "main is unchanged since $last_body ($last_sha); skipping." + else + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "main moved to $head_sha (last daily built $last_sha); building." + fi + + - name: Setup pnpm + if: steps.freshness.outputs.should_build == 'true' + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Setup Node.js + if: steps.freshness.outputs.should_build == 'true' + uses: actions/setup-node@v6 + with: + node-version-file: package.json + cache: pnpm + + - name: Cache electron-builder downloads + if: steps.freshness.outputs.should_build == 'true' + uses: actions/cache@v5 + with: + path: | + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + electron-builder-mac- + + - name: Install dependencies + if: steps.freshness.outputs.should_build == 'true' + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile + + # Why: signing is what makes a daily installable over an existing Orca, so + # a missing cert must fail here rather than after a 20-minute build. + - name: Verify macOS signing environment + if: steps.freshness.outputs.should_build == 'true' + run: node config/scripts/verify-macos-release-env.mjs + env: + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Compute daily version + id: daily + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + MAIN_REPO_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Existing titles, which carry the build number this series continues + # from. The script picks the number, because it restarts per base version + # and only the script knows which base this build resolved to. + # + # Why drafts count here but not in the freshness check: that check asks + # "did this commit ship", where a draft is a no. This one asks "is the + # number free", where a stranded draft still holds one. + names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \ + --jq '.[].name // empty')" + # Why the main repo's tags decide the base version rather than + # package.json: main's version only moves on `release:` commits, and + # stable patches are cut from release branches that never merge back, so + # package.json can sit several patches behind what users are running. A + # separate token because GH_TOKEN above is the App's, scoped to the + # daily repo. Empty on failure — the script then falls back to + # package.json, which is stale but never wrong enough to fail a build. + published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ + --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ + --json tagName --jq '.[].tagName' || true)" + echo "Highest published tag seen: $(head -1 <<<"$published")" + ORCA_PUBLISHED_VERSIONS="$published" ORCA_DAILY_RELEASE_NAMES="$names" \ + node config/scripts/daily-build-version.mjs \ + >"$RUNNER_TEMP/daily-identity.txt" + grep -E '^(version|build_number)=' "$RUNNER_TEMP/daily-identity.txt" + # Why check rather than trust: the checkout above pins `ref: main`, but a + # workflow_dispatch runs this file from whatever branch was dispatched. A + # branch that edits this step while main still has the old script yields + # an empty name and an untitled release — silent, and only visible once + # someone opens the releases page. Fail here instead. + if ! grep -q '^name=' "$RUNNER_TEMP/daily-identity.txt"; then + echo "::error::daily-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync." + exit 1 + fi + cat "$RUNNER_TEMP/daily-identity.txt" >>"$GITHUB_OUTPUT" + + - name: Build app + if: steps.freshness.outputs.should_build == 'true' + run: pnpm build:release + env: + NODE_OPTIONS: --max-old-space-size=4096 + # Why: daily builds are not an official channel — telemetry's transport + # gate accepts only 'stable' or 'rc', so leaving this unset keeps them + # silent, which is correct for unvetted dev artifacts. + ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token + + # Why a second mint: everything from here on writes to the daily repo, and + # the notary round trip inside the publish step can be tens of minutes. The + # token minted at the top has already spent install + build of its one hour + # on steps that never touched it; restarting the clock here gives the slow + # part the full budget. + - name: Re-mint daily repo token for publish + id: app_token_publish + if: steps.freshness.outputs.should_build == 'true' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-daily + + - name: Create daily release + id: release + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} + TAG: v${{ steps.daily.outputs.version }} + NAME: ${{ steps.daily.outputs.name }} + SHA: ${{ steps.freshness.outputs.head_sha }} + run: | + set -euo pipefail + # Kept at 12 even though the title shows 7: the freshness check above + # parses this back out of the body to decide whether main has moved. + short_sha="${SHA:0:12}" + # Why a file rather than an inline string: the publish step re-asserts + # this same body, and the freshness check only works if the two agree + # exactly. One source, written once, read twice. + notes_file="$RUNNER_TEMP/daily-release-notes.md" + cat >"$notes_file" <>"$GITHUB_OUTPUT" + echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT" + + - name: Publish daily macOS artifacts + if: steps.freshness.outputs.should_build == 'true' + uses: nick-fields/retry@v4 + with: + # Why 45 like the release pipeline: an attempt is pack + notarize + + # upload, and the notary queue is the unbounded part. Why 2 attempts and + # not 3: a missed daily costs a day, and a third attempt buys less than + # it costs in runner time once the notary is that stuck. + timeout_minutes: 45 + max_attempts: 2 + retry_wait_seconds: 30 + command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_DAILY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + env: + # Why: electron-builder's github publisher targets the repo named in the + # config; the token must therefore carry write access to orca-daily. + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} + ORCA_DAILY_BUILD_VERSION: ${{ steps.daily.outputs.version }} + ORCA_BUILD_COMMIT: ${{ steps.daily.outputs.commit }} + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + # Why all three: electron-builder's notarize step authenticates to the + # Apple notary service with the app-specific password, not with the + # signing cert. Omitting them fails the build rather than skipping it, + # since `notarize` is now on for this path. + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + # Why a third mint: the upload step above allows two 45-minute attempts, so + # it can outlive the one-hour token minted before it. Every remaining step + # writes to the daily repo, including the failure path that discards the + # draft — a 401 there is exactly the stranded draft nobody can clean up. + # + # Why always(): a failed or cancelled upload is the case that needs this + # most, since the cleanup step below runs only on that path. + - name: Re-mint daily repo token for verify and cleanup + id: app_token_final + if: always() && steps.freshness.outputs.should_build == 'true' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-daily + + # Why: the updater resolves a tag, then fetches latest-mac.yml from it. A + # release missing that manifest is a tag the picker offers and the download + # 404s on, so fail loudly instead of leaving a broken entry. + - name: Verify update manifest published + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_final.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + assets="$(gh release view "$TAG" --repo "$DAILY_REPO" --json assets --jq '.assets[].name')" + echo "Published assets:" + echo "$assets" + # Why exit 1 without deleting here: the release is still a draft, so it is + # already invisible to users, and the failure handler below owns cleanup. + # Deleting inline under `set -e` would also let the delete's exit code + # preempt this explicit failure. + for required in latest-mac.yml; do + if ! grep -qx "$required" <<<"$assets"; then + echo "::error::Daily draft $TAG is missing $required; the updater could not install it." + exit 1 + fi + done + if ! grep -q '\.zip$' <<<"$assets"; then + echo "::error::Daily draft $TAG has no ZIP artifact for the updater to download." + exit 1 + fi + + # Why this is the last mutating step: publishing the draft is what makes the + # build visible to listReleaseBuilds. Doing it only after the manifest check + # means the picker can never offer a release whose assets are incomplete. + - name: Publish the verified release + id: publish_live + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_final.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + NAME: ${{ steps.daily.outputs.name }} + NOTES_FILE: ${{ steps.release.outputs.notes_file }} + run: | + set -euo pipefail + # --title again: electron-builder resolves this draft by tag and may + # rewrite its title on upload. Re-asserting here means the name the + # picker reads is the one composed above, whatever it did in between. + # --notes-file for the same reason, and it matters more: the next run's + # freshness check parses the source commit back out of this body, so a + # body the publisher overwrote would rebuild an unchanged main daily. + gh release edit "$TAG" --repo "$DAILY_REPO" --draft=false --prerelease \ + --title "$NAME" --notes-file "$NOTES_FILE" + echo "Published $TAG as \"$NAME\"" + + # Why: a draft left behind by a failed publish is invisible to users but still + # holds its tag name, so the next run for the same minute would collide. + # + # Why it is gated on publish_live not having succeeded: a later failure (the + # prune step) must not delete a release that already went live and that users + # may already be installing. A job killed by the outer timeout runs no steps + # at all — which is exactly why the release stays a draft until verified. + # Why cancelled() too: a run stopped from the Actions UI is not a failure(), + # so without it a manual cancel mid-publish would strand the draft. + - name: Discard the draft release on failure + if: >- + (failure() || cancelled()) && steps.release.outputs.tag != '' && + steps.publish_live.outcome != 'success' + shell: bash + env: + # Fall back to the publish token: if the re-mint itself is what failed, + # the older token is the only one left and may still have time on it. + GH_TOKEN: ${{ steps.app_token_final.outputs.token || steps.app_token_publish.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -uo pipefail + # No --cleanup-tag: an unpublished draft never created a git tag. + echo "Run failed before publish; discarding draft $TAG" + gh release delete "$TAG" --repo "$DAILY_REPO" --yes || + echo "::warning::Could not discard draft $TAG; remove it manually." + + - name: Prune old daily releases + # Only after a live publish: $TAG is then a non-draft we must not delete, + # and failed runs should not reshuffle retention around a draft that the + # failure path is about to discard. + if: steps.publish_live.outcome == 'success' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_final.outputs.token }} + # Protect the tag this run just shipped; at the retain cap, a bad sort + # can otherwise mark the newest release as stale and delete it. + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + # Why: --cleanup-tag so pruning does not leave orphan tags behind that + # keep showing up in tag lists with no release or assets attached. + # Drafts are excluded so retention counts shipped builds only; a stale + # draft is handled by the failure path, not by the retention window. + # + # Sort by publishedAt (not createdAt). Many non-draft dailies can share + # one createdAt (bulk import / re-create), so createdAt ranking is + # unstable. publishedAt is real recency; tagName (...YYYYMMDDHHMM) is + # the deterministic tie-break. + # + # Why force $TAG to the front before slicing: a hard retain-window seat + # for this run's release. Dropping $TAG from the list *before* the slice + # would permanently keep retain+1 releases; skipping it only in the + # delete loop would under-prune when the sort is still wrong. Partition + # keeps relative order of every other tag. + jq_filter='map(select(.isDraft | not)) | sort_by(.publishedAt // "", .tagName) | reverse' + if [[ -n "${TAG:-}" ]]; then + jq_filter+=" | (map(select(.tagName == \"${TAG//\"/\\\"}\")) + map(select(.tagName != \"${TAG//\"/\\\"}\")))" + fi + jq_filter+=" | .[${DAILY_RETAIN_COUNT}:] | .[].tagName" + stale="$(gh release list --repo "$DAILY_REPO" --limit 200 --json tagName,publishedAt,isDraft \ + --jq "$jq_filter")" + if [[ -z "$stale" ]]; then + echo "Nothing to prune; at or under $DAILY_RETAIN_COUNT retained builds." + exit 0 + fi + while read -r tag; do + [[ -n "$tag" ]] || continue + # Belt-and-suspenders: partition above should already exclude $TAG. + if [[ -n "${TAG:-}" && "$tag" == "$TAG" ]]; then + echo "::warning::Prune list still included just-published $tag after protect; skipping delete." + continue + fi + echo "Pruning $tag" + gh release delete "$tag" --repo "$DAILY_REPO" --yes --cleanup-tag || \ + echo "::warning::Could not prune $tag" + done <<<"$stale" diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index e581cd7a44e..9933df8156e 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -20,21 +20,32 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime. // Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to // swap them over an installed Orca and macOS treats each build as a new app. const isMacHourly = process.env.ORCA_MAC_HOURLY === '1' +const isMacDaily = process.env.ORCA_MAC_DAILY === '1' const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1' -const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacAdhoc +const isMacRelease = + process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION const devChannelBuildVersion = isMacHourly ? process.env.ORCA_HOURLY_BUILD_VERSION - : isMacAdhoc - ? process.env.ORCA_ADHOC_BUILD_VERSION - : undefined + : isMacDaily + ? process.env.ORCA_DAILY_BUILD_VERSION + : isMacAdhoc + ? process.env.ORCA_ADHOC_BUILD_VERSION + : undefined // Why each dev channel gets its own repo rather than tagging into the main one: // the releases atom feed exposes only the 10 newest entries, so 24 hourly tags a // day would evict every stable/RC entry and strand users on a feed with nothing -// to install. Keeping adhoc separate from hourly too means a branch build cannot -// be picked up by someone who only meant to ride main. -const devChannelRepo = isMacHourly ? 'orca-hourly' : isMacAdhoc ? 'orca-adhoc' : null +// to install. Keeping adhoc/daily separate from hourly too means a branch build +// or a once-a-day cut cannot be picked up by someone who only meant to ride +// main's hourlies. +const devChannelRepo = isMacHourly + ? 'orca-hourly' + : isMacDaily + ? 'orca-daily' + : isMacAdhoc + ? 'orca-adhoc' + : null const appId = 'com.stablyai.orca' const featureWallResources = { from: 'resources/onboarding/feature-wall', diff --git a/config/scripts/daily-build-version.mjs b/config/scripts/daily-build-version.mjs new file mode 100755 index 00000000000..1d27ef5fb34 --- /dev/null +++ b/config/scripts/daily-build-version.mjs @@ -0,0 +1,109 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs' +import { + readPublishedVersionsFromEnv, + resolveDevChannelBaseVersion +} from './dev-channel-base-version.mjs' + +/** `1.4.160-daily.202607281300` — UTC to the minute, so tags sort chronologically + * by semver and every build is uniquely versioned. */ +export function createDailyBuildVersion(baseVersion, date) { + const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion) + if (!match) { + throw new Error(`Package version is not valid semver: ${baseVersion}`) + } + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + throw new Error('Daily build timestamp is invalid.') + } + const pad = (value, width = 2) => String(value).padStart(width, '0') + const stamp = [ + pad(date.getUTCFullYear(), 4), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + pad(date.getUTCHours()), + pad(date.getUTCMinutes()) + ].join('') + // Why: drop any -rc.N tail. Keeping it makes every daily semver-NEWER than the + // RC it was cut from (1.4.160-rc.3-daily.X > 1.4.160-rc.3), which would let an + // ordinary RC-channel check offer untested daily builds to RC users. Stripping + // to the base parks dailies below both rc.N and stable ('daily' < 'rc' + // alphabetically), reachable only by an explicit pinned jump. + return `${match[1]}-daily.${stamp}` +} + +/** + * The next build number for `baseVersion`, counting from the titles of existing + * daily releases. + * + * Why the series restarts at 01 on every base version: the number answers "which + * build of 1.4.163 is this", so a counter shared across versions makes it + * meaningless — 1.4.164 would open at 38 for no reason a reader can see. + * + * Why the maximum rather than a count: the prune step trims to + * DAILY_RETAIN_COUNT, so a count would roll backwards and reissue a number + * already in use. Titles that predate this naming simply do not match, which is + * how the first build of a version lands on 01. + */ +export function nextDailyBuildNumber(baseVersion, releaseNames = []) { + const prefix = `${baseVersion} • ` + const highest = releaseNames.reduce((max, entry) => { + const name = String(entry ?? '') + if (!name.startsWith(prefix)) { + return max + } + const match = /^(\d+) • /.exec(name.slice(prefix.length)) + return match ? Math.max(max, Number(match[1])) : max + }, 0) + return highest + 1 +} + +/** + * `1.4.163 • 01 • Aug 9, 6:15AM • e698241` — the human-facing release title, + * shown verbatim in both the GitHub releases list and the in-app build picker. + */ +export function formatDailyReleaseName(version, buildNumber, commit, date) { + if (!Number.isInteger(buildNumber) || buildNumber < 1) { + throw new Error(`Daily build number must be a positive integer: ${buildNumber}`) + } + return [ + version.split('-')[0], + String(buildNumber).padStart(2, '0'), + formatReleaseTitleTimestamp(date), + commit.slice(0, 7) + ].join(' • ') +} + +// Why the number is derived here rather than passed in: it counts builds of the +// base version, and the base is only known once the published tags have been +// resolved just above. Computing it outside meant numbering against whatever +// version the caller guessed. +export function getDailyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) { + const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) + const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { + encoding: 'utf8' + }).trim() + const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? []) + const version = createDailyBuildVersion(base, now) + const buildNumber = nextDailyBuildNumber(base, releaseNames ?? []) + return { + commit, + version, + buildNumber, + name: formatDailyReleaseName(version, buildNumber, commit, now) + } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const identity = getDailyBuildIdentity(new Date(), { + publishedVersions: readPublishedVersionsFromEnv(), + // Titles are newline separated and contain spaces, so this cannot reuse the + // whitespace split the version list gets. + releaseNames: (process.env.ORCA_DAILY_RELEASE_NAMES ?? '').split('\n').filter(Boolean) + }) + // Consumed by the workflow via $GITHUB_OUTPUT. + process.stdout.write( + `version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n` + ) +} diff --git a/config/scripts/daily-build-version.test.mjs b/config/scripts/daily-build-version.test.mjs new file mode 100644 index 00000000000..7f0e71b92c9 --- /dev/null +++ b/config/scripts/daily-build-version.test.mjs @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { + createDailyBuildVersion, + formatDailyReleaseName, + nextDailyBuildNumber +} from './daily-build-version.mjs' +import { compareAppVersions } from '../../src/shared/app-version' + +describe('createDailyBuildVersion', () => { + it('stamps the version with a zero-padded UTC timestamp', () => { + expect(createDailyBuildVersion('1.4.160', new Date('2026-07-28T13:00:00Z'))).toBe( + '1.4.160-daily.202607281300' + ) + }) + + // Why: main's package.json carries the in-flight RC tail. Keeping it would make + // every daily semver-NEWER than the RC it was cut from (1.4.160-rc.3-daily.X > + // 1.4.160-rc.3), so an ordinary RC-channel check would offer untested daily + // builds to RC users. Dropping it parks dailies below both rc.N and stable, + // reachable only by an explicit pinned jump. + it('drops an in-flight rc tail so dailies never outrank the rc series', () => { + const version = createDailyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T13:00:00Z')) + expect(version).toBe('1.4.160-daily.202607281300') + expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0) + expect(compareAppVersions('1.4.160-rc.3-daily.202607281300', '1.4.160-rc.3')).toBeGreaterThan(0) + }) + + // Why: 'daily' sorts before 'hourly' alphabetically, so a daily of the same + // base never outranks an hourly — both stay below rc/stable and only the + // channel picker offers them. + it('sorts below the hourly build of the same base version', () => { + expect( + compareAppVersions('1.4.160-daily.202607281300', '1.4.160-hourly.202607281400') + ).toBeLessThan(0) + }) + + it('rejects invalid input', () => { + expect(() => createDailyBuildVersion('nope', new Date())).toThrow(/valid semver/) + expect(() => createDailyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/) + }) +}) + +describe('formatDailyReleaseName', () => { + const name = (iso, buildNumber = 1, commit = 'e698241abcde') => + formatDailyReleaseName('1.4.163-daily.x', buildNumber, commit, new Date(iso)) + + it('renders version, number, Pacific timestamp, and short sha', () => { + // 13:15 UTC is 6:15AM PDT in July (the daily cut time). + expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241') + }) + + // Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so + // the offset between them is not a constant. A test pinned to one season would + // pass all summer and start failing in November. + it('follows the Pacific offset across DST', () => { + expect(name('2026-01-15T14:15:00Z')).toBe('1.4.163 • 01 • Jan 15, 6:15AM • e698241') + expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241') + }) + + it('pads to two digits and grows past them', () => { + expect(name('2026-07-28T13:15:00Z', 9)).toContain(' • 09 • ') + expect(name('2026-07-28T13:15:00Z', 42)).toContain(' • 42 • ') + }) + + it('rejects a build number that is not a positive integer', () => { + expect(() => name('2026-07-28T13:15:00Z', 0)).toThrow(/positive integer/) + expect(() => name('2026-07-28T13:15:00Z', -1)).toThrow(/positive integer/) + expect(() => name('2026-07-28T13:15:00Z', 1.5)).toThrow(/positive integer/) + }) + + it('rejects an invalid timestamp', () => { + expect(() => formatDailyReleaseName('1.4.163', 1, 'abcdefg', new Date('nope'))).toThrow( + /invalid/ + ) + }) +}) + +describe('nextDailyBuildNumber', () => { + const titles = [ + '1.4.163 • 01 • Jul 28, 6:00AM • e698241', + '1.4.163 • 02 • Jul 29, 6:00AM • aaaaaaa', + '1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb' + ] + + it('continues the series for the version being built', () => { + expect(nextDailyBuildNumber('1.4.163', titles)).toBe(10) + }) + + it('restarts at 1 when the base version moves', () => { + expect(nextDailyBuildNumber('1.4.164', titles)).toBe(1) + expect( + nextDailyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 6:00AM • ccccccc']) + ).toBe(2) + }) + + // Why max and not count: pruning trims to DAILY_RETAIN_COUNT, so counting + // would roll backwards and reissue a number already used. + it('takes the highest number, not the count', () => { + expect(nextDailyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 6:00AM • e698241'])).toBe(10) + }) + + it('starts at 1 with no history at all', () => { + expect(nextDailyBuildNumber('1.4.163')).toBe(1) + expect(nextDailyBuildNumber('1.4.163', [])).toBe(1) + }) + + it('ignores titles that are not this version', () => { + expect(nextDailyBuildNumber('1.4.16', ['1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb'])).toBe(1) + expect(nextDailyBuildNumber('1.4.163', ['v1.4.163-daily.202607311300', null, ''])).toBe(1) + }) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 3227d957b24..2ecda0e80eb 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -19,42 +19,6 @@ const { verifyPackagedMainRuntimeDeps } = require('../packaged-runtime-node-modules.cjs') -const MUTABLE_BUILD_ENV = [ - 'ORCA_MAC_HOURLY', - 'ORCA_MAC_ADHOC', - 'ORCA_MAC_RELEASE', - 'ORCA_HOURLY_BUILD_VERSION', - 'ORCA_ADHOC_BUILD_VERSION', - 'ORCA_LOCAL_BUILD_VERSION' -] - -/** Re-requires the config under a temporary env, then restores env and module cache. */ -function withEnv(env, assert) { - const configPath = require.resolve('../electron-builder.config.cjs') - const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]])) - try { - for (const key of MUTABLE_BUILD_ENV) { - delete process.env[key] - } - Object.assign(process.env, env) - delete require.cache[configPath] - assert(require('../electron-builder.config.cjs')) - } finally { - for (const [key, value] of Object.entries(original)) { - if (value === undefined) { - delete process.env[key] - } else { - process.env[key] = value - } - } - delete require.cache[configPath] - require('../electron-builder.config.cjs') - } -} - -const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert) -const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert) - describe('electron-builder config', () => { it('keeps the packaged app identity aligned with local-build validation', () => { expect(electronBuilderConfig.appId).toBe( @@ -303,89 +267,6 @@ describe('electron-builder config', () => { } }) - // Why: Squirrel.Mac swaps the .app in place only when the replacement carries the - // same bundle id and a valid Developer ID signature. A hourly built on the local - // (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real - // Orca — the whole point of the channel. - it('builds hourly artifacts with the release signing identity', () => { - withHourlyEnv((config) => { - expect(config.mac.appId).toBeUndefined() - expect(config.appId).toBe('com.stablyai.orca') - expect(config.mac.hardenedRuntime).toBe(true) - expect(config.forceCodeSigning).toBe(true) - }) - }) - - // Why hourly must notarize despite the round trip: TCC anchors a notarized - // Developer ID app's grants on identifier + team, not on its cdhash, so they - // survive an update. An unnotarized hourly reads as a new client every build - // and loses file access under Documents/Desktop/Downloads with no re-prompt. - it('notarizes hourly builds like releases, and neither locally', () => { - withHourlyEnv((config) => { - expect(config.mac.notarize).toBe(true) - }) - withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => { - expect(config.mac.notarize).toBe(true) - }) - expect(electronBuilderConfig.mac.notarize).toBe(false) - }) - - // Why: the main repo's releases atom feed exposes only its 10 newest entries. - // Publishing 24 hourly tags a day there would evict every stable/RC entry and - // break update checks for every real user. - it('publishes hourly builds to the separate hourly repo', () => { - withHourlyEnv((config) => { - expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' }) - }) - expect(electronBuilderConfig.publish).toMatchObject({ - repo: 'orca', - releaseType: 'release' - }) - }) - - it('stamps hourly packages with the hourly version', () => { - withEnv( - { ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' }, - (config) => { - expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' }) - } - ) - }) - - // Why adhoc carries the identical mac identity to hourly: it installs over a - // real Orca through the same updater path, so the same signing and the same TCC - // argument apply. Only the destination repo differs. - it('builds adhoc artifacts with the release identity and its own repo', () => { - withAdhocEnv((config) => { - expect(config.appId).toBe('com.stablyai.orca') - expect(config.mac.hardenedRuntime).toBe(true) - expect(config.mac.notarize).toBe(true) - expect(config.forceCodeSigning).toBe(true) - expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' }) - }) - }) - - it('stamps adhoc packages with the adhoc version', () => { - withEnv( - { ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' }, - (config) => { - expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' }) - } - ) - }) - - // Why: the two dev channels share every packaging decision except where they - // publish, so a future edit that collapses them must not also collapse the - // repos — a branch build landing in orca-hourly would be offered to everyone - // riding main. - it('keeps the two dev channels on separate repos', () => { - withHourlyEnv((hourly) => { - withAdhocEnv((adhoc) => { - expect(hourly.publish.repo).not.toBe(adhoc.publish.repo) - }) - }) - }) - it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => { expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild) expect(electronBuilderConfig.npmRebuild).toBe(true) diff --git a/config/scripts/electron-builder-mac-channel-config.test.mjs b/config/scripts/electron-builder-mac-channel-config.test.mjs new file mode 100644 index 00000000000..dbd5a1170a1 --- /dev/null +++ b/config/scripts/electron-builder-mac-channel-config.test.mjs @@ -0,0 +1,152 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const electronBuilderConfig = require('../electron-builder.config.cjs') + +const MUTABLE_BUILD_ENV = [ + 'ORCA_MAC_HOURLY', + 'ORCA_MAC_DAILY', + 'ORCA_MAC_ADHOC', + 'ORCA_MAC_RELEASE', + 'ORCA_HOURLY_BUILD_VERSION', + 'ORCA_DAILY_BUILD_VERSION', + 'ORCA_ADHOC_BUILD_VERSION', + 'ORCA_LOCAL_BUILD_VERSION' +] + +/** Re-requires the config under a temporary env, then restores env and module cache. */ +function withEnv(env, assert) { + const configPath = require.resolve('../electron-builder.config.cjs') + const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]])) + try { + for (const key of MUTABLE_BUILD_ENV) { + delete process.env[key] + } + Object.assign(process.env, env) + delete require.cache[configPath] + assert(require('../electron-builder.config.cjs')) + } finally { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + delete require.cache[configPath] + require('../electron-builder.config.cjs') + } +} + +const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert) +const withDailyEnv = (assert) => withEnv({ ORCA_MAC_DAILY: '1' }, assert) +const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert) + +describe('electron-builder mac channel config', () => { + // Why: Squirrel.Mac swaps the .app in place only when the replacement carries the + // same bundle id and a valid Developer ID signature. A hourly built on the local + // (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real + // Orca — the whole point of the channel. + it('builds hourly artifacts with the release signing identity', () => { + withHourlyEnv((config) => { + expect(config.mac.appId).toBeUndefined() + expect(config.appId).toBe('com.stablyai.orca') + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.forceCodeSigning).toBe(true) + }) + }) + + // Why hourly must notarize despite the round trip: TCC anchors a notarized + // Developer ID app's grants on identifier + team, not on its cdhash, so they + // survive an update. An unnotarized hourly reads as a new client every build + // and loses file access under Documents/Desktop/Downloads with no re-prompt. + it('notarizes hourly builds like releases, and neither locally', () => { + withHourlyEnv((config) => { + expect(config.mac.notarize).toBe(true) + }) + withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => { + expect(config.mac.notarize).toBe(true) + }) + expect(electronBuilderConfig.mac.notarize).toBe(false) + }) + + // Why: the main repo's releases atom feed exposes only its 10 newest entries. + // Publishing 24 hourly tags a day there would evict every stable/RC entry and + // break update checks for every real user. + it('publishes hourly builds to the separate hourly repo', () => { + withHourlyEnv((config) => { + expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' }) + }) + expect(electronBuilderConfig.publish).toMatchObject({ + repo: 'orca', + releaseType: 'release' + }) + }) + + it('stamps hourly packages with the hourly version', () => { + withEnv( + { ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' }, + (config) => { + expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' }) + } + ) + }) + + // Why adhoc carries the identical mac identity to hourly: it installs over a + // real Orca through the same updater path, so the same signing and the same TCC + // argument apply. Only the destination repo differs. + it('builds adhoc artifacts with the release identity and its own repo', () => { + withAdhocEnv((config) => { + expect(config.appId).toBe('com.stablyai.orca') + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.mac.notarize).toBe(true) + expect(config.forceCodeSigning).toBe(true) + expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' }) + }) + }) + + it('stamps adhoc packages with the adhoc version', () => { + withEnv( + { ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' }, + (config) => { + expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' }) + } + ) + }) + + it('builds daily artifacts with the release identity and its own repo', () => { + withDailyEnv((config) => { + expect(config.appId).toBe('com.stablyai.orca') + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.mac.notarize).toBe(true) + expect(config.forceCodeSigning).toBe(true) + expect(config.publish).toMatchObject({ repo: 'orca-daily', releaseType: 'prerelease' }) + }) + }) + + it('stamps daily packages with the daily version', () => { + withEnv( + { ORCA_MAC_DAILY: '1', ORCA_DAILY_BUILD_VERSION: '1.4.160-daily.202607281300' }, + (config) => { + expect(config.extraMetadata).toEqual({ version: '1.4.160-daily.202607281300' }) + } + ) + }) + + // Why: the dev channels share every packaging decision except where they + // publish, so a future edit that collapses them must not also collapse the + // repos — a branch or daily build landing in orca-hourly would be offered to + // everyone riding main's hourlies. + it('keeps the dev channels on separate repos', () => { + withHourlyEnv((hourly) => { + withDailyEnv((daily) => { + withAdhocEnv((adhoc) => { + expect(new Set([hourly.publish.repo, daily.publish.repo, adhoc.publish.repo]).size).toBe( + 3 + ) + }) + }) + }) + }) +}) diff --git a/config/scripts/setup-adhoc-release-repo.sh b/config/scripts/setup-adhoc-release-repo.sh index 3e232c5fce8..1f3debf125c 100755 --- a/config/scripts/setup-adhoc-release-repo.sh +++ b/config/scripts/setup-adhoc-release-repo.sh @@ -86,7 +86,7 @@ Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in 1. Open: https://github.com/organizations/$ORG/settings/installations 2. Configure -> $APP_SLUG 3. Repository access -> Only select repositories -> add $ADHOC_REPO - (keep orca-hourly selected; both dev channels use this one App) + (keep orca-hourly/orca-daily selected; all dev channels use this one App) 4. Save. EOF fi diff --git a/config/scripts/setup-daily-release-repo.sh b/config/scripts/setup-daily-release-repo.sh new file mode 100755 index 00000000000..e9437c0c64a --- /dev/null +++ b/config/scripts/setup-daily-release-repo.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# Creates stablyai/orca-daily and grants the existing release App write access to +# it, so daily-mac-build.yml can publish there. +# +# Why a separate repo rather than reusing orca-hourly: the daily channel is a +# once-a-day cut that people ride deliberately. Sharing hourly's list would mix a +# sparse daily series into the 72-entry hourly retention window and make both +# pickers harder to read. +# +# Why no secrets are set here: the daily workflow reuses the same GitHub App as +# hourly/adhoc — one App id, one private key, one thing to rotate. This script +# only has to widen that App's installation to cover the new repo. +# +# Run once, after config/scripts/setup-hourly-release-token.sh: +# bash config/scripts/setup-daily-release-repo.sh +# +set -euo pipefail + +ORG="stablyai" +DAILY_REPO="$ORG/orca-daily" +MAIN_REPO="$ORG/orca" +APP_SLUG="orca-hourly-release" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com" +gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login" + +if gh api "repos/$DAILY_REPO" --jq '.full_name' >/dev/null 2>&1; then + echo "$DAILY_REPO already exists." +else + echo "Creating $DAILY_REPO..." + # Why public: the in-app updater fetches release assets unauthenticated, exactly + # as it does for orca-hourly. A private repo would 404 for every client. + # + # Why the features are off: this repo holds releases and nothing else. Leaving + # issues open invites bug reports against an unvetted daily in a repo nobody + # watches, where they are simply lost. + # + # Why --add-readme in a repo with no source: publishing a release creates a tag, + # and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in. + gh repo create "$DAILY_REPO" \ + --public \ + --description "Daily macOS dev builds of Orca, cut from main each morning. Not a source repo." \ + --add-readme \ + --disable-issues \ + --disable-wiki || + fail "Could not create $DAILY_REPO." +fi + +# Also checked outside the create branch: a repo made before --add-readme is here. +if ! gh api "repos/$DAILY_REPO/commits" --jq 'length' >/dev/null 2>&1; then + fail "$DAILY_REPO has no commits — releases cannot be tagged. Add any file to it first." +fi + +echo +echo "Granting $APP_SLUG access to $DAILY_REPO..." + +# Why attempt the API before printing instructions: an org owner can do this in +# one call. Everyone else gets a 403 and the manual path below — GitHub does not +# let a mere admin widen an App's repository selection. +INSTALL_ID="$(gh api "orgs/$ORG/installations" --paginate \ + --jq ".installations[] | select(.app_slug == \"$APP_SLUG\") | .id" 2>/dev/null || true)" +REPO_ID="$(gh api "repos/$DAILY_REPO" --jq '.id' 2>/dev/null || true)" + +GRANTED=false +if [[ -n "$INSTALL_ID" && -n "$REPO_ID" ]]; then + if gh api -X PUT "user/installations/$INSTALL_ID/repositories/$REPO_ID" >/dev/null 2>&1; then + GRANTED=true + echo "Done — $APP_SLUG can now write to $DAILY_REPO." + fi +fi + +if [[ "$GRANTED" != "true" ]]; then + # Why no automated check afterwards: the endpoints that report an App's + # repository access (repos/*/installation, user/installations/*/repositories) + # both reject an ordinary `gh auth login` token, so any "verified" this script + # printed would be guesswork. The smoke test below is the real check. + cat < $APP_SLUG + 3. Repository access -> Only select repositories -> add $DAILY_REPO + (keep orca-hourly and orca-adhoc selected; all dev channels use this one App) + 4. Save. +EOF +fi + +echo +echo "Smoke-test the pipeline (after this merges):" +echo " gh workflow run daily-mac-build.yml --repo $MAIN_REPO -f force=true" +echo " gh run watch --repo $MAIN_REPO" diff --git a/config/scripts/setup-hourly-release-token.sh b/config/scripts/setup-hourly-release-token.sh index 3d846bc01d7..77f6b483445 100755 --- a/config/scripts/setup-hourly-release-token.sh +++ b/config/scripts/setup-hourly-release-token.sh @@ -8,9 +8,10 @@ # — no yearly rotation — and it belongs to the org rather than to the person who # created it, so it survives that person leaving. # -# The same App also serves adhoc-mac-build.yml, which reads these same two -# secrets: one credential, one rotation, both dev channels. Widening it to cover -# stablyai/orca-adhoc is config/scripts/setup-adhoc-release-repo.sh's job. +# The same App also serves adhoc-mac-build.yml and daily-mac-build.yml, which +# read these same two secrets: one credential, one rotation, all dev channels. +# Widening it to cover stablyai/orca-adhoc / orca-daily is +# setup-adhoc-release-repo.sh / setup-daily-release-repo.sh's job. # # The key is read from a file and piped straight into `gh secret set`. It is never # echoed, never passed as a command-line argument (argv is world-readable via diff --git a/src/main/updater-release-builds.test.ts b/src/main/updater-release-builds.test.ts index b9344954a5a..5e7b55e9f75 100644 --- a/src/main/updater-release-builds.test.ts +++ b/src/main/updater-release-builds.test.ts @@ -45,6 +45,25 @@ describe('listReleaseBuilds', () => { ]) }) + it('lists daily builds from the dedicated repo, newest first', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + release('v1.4.160-daily.202607271300'), + release('v1.4.160-daily.202607291300'), + release('v1.4.160-daily.202607281300') + ]) + ) + + const builds = await listReleaseBuilds('daily') + + expect(fetchMock.mock.calls[0][0]).toContain('stablyai/orca-daily') + expect(builds.map((build) => build.version)).toEqual([ + '1.4.160-daily.202607291300', + '1.4.160-daily.202607281300', + '1.4.160-daily.202607271300' + ]) + }) + // Why: the main repo serves stable and rc from one endpoint, so an unfiltered // list would offer RC tags under the Stable channel. it('separates stable from rc in the shared main repo', async () => { @@ -124,6 +143,15 @@ describe('resolveTargetBuild', () => { }) }) + it('pins a daily tag at the daily repo download path', () => { + expect(resolveTargetBuild('daily', 'v1.4.160-daily.202607281300')).toEqual({ + tag: 'v1.4.160-daily.202607281300', + version: '1.4.160-daily.202607281300', + feedUrl: + 'https://github.com/stablyai/orca-daily/releases/download/v1.4.160-daily.202607281300' + }) + }) + it('pins a stable tag at the main repo download path', () => { expect(resolveTargetBuild('stable', 'v1.4.159').feedUrl).toBe( 'https://github.com/stablyai/orca/releases/download/v1.4.159' diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index dce8b39a7b6..1f5cdc06994 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -301,6 +301,7 @@ describe('updater', () => { it.each([ ['hourly', 'v1.4.160-hourly.202607281400', 'Hourly builds are produced only for macOS.'], + ['daily', 'v1.4.160-daily.202607281300', 'Daily builds are produced only for macOS.'], ['adhoc', 'v1.4.160-adhoc.20260728140533', 'Adhoc builds are produced only for macOS.'] ] as const)( 'uses the display label in the mac-only %s pinned-build error', diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx index b89d8aa5084..bfe2321bd32 100644 --- a/src/renderer/src/components/settings/ReleaseChannelSection.tsx +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -16,6 +16,7 @@ import { hasDedicatedReleaseRepo, isChannelSupportedOnPlatform, parseDevBuildStamp, + type DedicatedRepoChannel, type ReleaseBuild, type ReleaseChannel } from '../../../../shared/release-channel' @@ -24,9 +25,30 @@ const CHANNEL_DESCRIPTIONS: Record = { stable: 'Shipped releases. What everyone else is running.', rc: 'Release candidates cut ahead of each stable.', hourly: 'macOS only. Unvetted builds from main, built every hour. No tests.', + daily: + 'macOS only. Unvetted builds from main, cut once a day at 14:15 UTC (early morning Pacific). No tests.', adhoc: 'macOS only. One-off builds cut from a branch to try a feature before it lands.' } +const DEDICATED_CHANNEL_WARNINGS: Record = + { + hourly: { + key: 'auto.components.settings.ReleaseChannelSection.hourlyWarning', + fallback: + 'Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.' + }, + daily: { + key: 'auto.components.settings.ReleaseChannelSection.dailyWarning', + fallback: + 'Daily builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.' + }, + adhoc: { + key: 'auto.components.settings.ReleaseChannelSection.adhocWarning', + fallback: + 'Adhoc builds are macOS-only and come from a branch that has not landed. Whoever cut one may abandon it — keep a stable build handy.' + } + } + function formatBuildLabel(build: ReleaseBuild): string { // Why the release's own title wins: the build workflows compose it (hourly // `1.4.163 • 01 • Jul 31, 1:54PM • e698241`, adhoc `1.4.163 • wasm-terminal • …`), @@ -226,15 +248,10 @@ export function ReleaseChannelSection(): React.JSX.Element {

- {activeChannel === 'hourly' - ? translate( - 'auto.components.settings.ReleaseChannelSection.hourlyWarning', - 'Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.' - ) - : translate( - 'auto.components.settings.ReleaseChannelSection.adhocWarning', - 'Adhoc builds are macOS-only and come from a branch that has not landed. Whoever cut one may abandon it — keep a stable build handy.' - )} + {translate( + DEDICATED_CHANNEL_WARNINGS[activeChannel].key, + DEDICATED_CHANNEL_WARNINGS[activeChannel].fallback + )}

) : null} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 08fd9b389c8..3e0980342eb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10389,6 +10389,7 @@ "devOnly": "Dev only", "channelAriaLabel": "Update channel", "hourlyWarning": "Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.", + "dailyWarning": "Daily builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.", "adhocWarning": "Adhoc builds are macOS-only and come from a branch that has not landed. Whoever cut one may abandon it — keep a stable build handy.", "loadingBuilds": "Loading builds…", "noBuilds": "No builds found", diff --git a/src/shared/release-channel.test.ts b/src/shared/release-channel.test.ts index a12cc94c929..fc7172efbf6 100644 --- a/src/shared/release-channel.test.ts +++ b/src/shared/release-channel.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { formatAdhocVersion, + formatDailyVersion, formatHourlyVersion, getReleaseNotesUrlForVersion, getReleaseRepoForChannel, @@ -8,9 +9,11 @@ import { hasDedicatedReleaseRepo, isAdhocVersion, isChannelSupportedOnPlatform, + isDailyVersion, isHourlyVersion, isReleaseChannel, parseAdhocVersionStamp, + parseDailyVersionStamp, parseDevBuildStamp, parseHourlyVersionStamp, sortReleaseBuildsNewestFirst, @@ -24,6 +27,7 @@ describe('release channel', () => { expect(getVersionChannel('v1.4.160')).toBe('stable') expect(getVersionChannel('1.4.160-rc.3')).toBe('rc') expect(getVersionChannel('1.4.160-hourly.202607281400')).toBe('hourly') + expect(getVersionChannel('1.4.160-daily.202607281300')).toBe('daily') expect(getVersionChannel('1.4.160-adhoc.20260728140533')).toBe('adhoc') expect(getVersionChannel('not-a-version')).toBeNull() }) @@ -33,7 +37,8 @@ describe('release channel', () => { // entry and leave real users with nothing to update to. it('keeps dev builds out of the main release repo, and apart from each other', () => { expect(getReleaseRepoForChannel('hourly')).toBe('stablyai/orca-hourly') - // Why adhoc gets a third repo rather than sharing hourly's: an unlanded + expect(getReleaseRepoForChannel('daily')).toBe('stablyai/orca-daily') + // Why adhoc gets its own repo rather than sharing hourly's: an unlanded // branch build must never surface to someone who only meant to ride main. expect(getReleaseRepoForChannel('adhoc')).toBe('stablyai/orca-adhoc') expect(getReleaseRepoForChannel('stable')).toBe('stablyai/orca') @@ -42,6 +47,7 @@ describe('release channel', () => { it('marks exactly the dev channels as having their own repo', () => { expect(hasDedicatedReleaseRepo('hourly')).toBe(true) + expect(hasDedicatedReleaseRepo('daily')).toBe(true) expect(hasDedicatedReleaseRepo('adhoc')).toBe(true) expect(hasDedicatedReleaseRepo('stable')).toBe(false) expect(hasDedicatedReleaseRepo('rc')).toBe(false) @@ -53,6 +59,9 @@ describe('release channel', () => { expect(getReleaseNotesUrlForVersion('1.4.160-hourly.202607281400')).toBe( 'https://github.com/stablyai/orca-hourly/releases/tag/v1.4.160-hourly.202607281400' ) + expect(getReleaseNotesUrlForVersion('1.4.160-daily.202607281300')).toBe( + 'https://github.com/stablyai/orca-daily/releases/tag/v1.4.160-daily.202607281300' + ) expect(getReleaseNotesUrlForVersion('1.4.160')).toBe( 'https://github.com/stablyai/orca/releases/tag/v1.4.160' ) @@ -71,6 +80,12 @@ describe('release channel', () => { expect(parseHourlyVersionStamp(version)?.toISOString()).toBe('2026-07-28T14:05:00.000Z') }) + it('round-trips a daily version stamp as UTC', () => { + const version = formatDailyVersion('1.4.160', '202607281300') + expect(isDailyVersion(version)).toBe(true) + expect(parseDailyVersionStamp(version)?.toISOString()).toBe('2026-07-28T13:00:00.000Z') + }) + it('rejects malformed hourly identifiers', () => { expect(isHourlyVersion('1.4.160-hourly')).toBe(false) expect(isHourlyVersion('1.4.160-hourly.2026')).toBe(false) @@ -91,6 +106,8 @@ describe('release channel', () => { expect(parseHourlyVersionStamp('1.4.160-hourly.202802290000')?.toISOString()).toBe( '2028-02-29T00:00:00.000Z' ) + expect(parseDailyVersionStamp('1.4.160-daily.202602300000')).toBeNull() + expect(parseDailyVersionStamp('not-a-version-daily.202601010000')).toBeNull() }) // Why seconds and not hourly's minutes: adhoc builds are dispatched on demand, @@ -102,9 +119,12 @@ describe('release channel', () => { expect(parseAdhocVersionStamp(version)?.toISOString()).toBe('2026-07-28T14:05:33.000Z') }) - it('keeps the two dev stamp formats from matching each other', () => { + it('keeps the dev stamp formats from matching each other', () => { expect(isAdhocVersion('1.4.160-hourly.202607281400')).toBe(false) expect(isHourlyVersion('1.4.160-adhoc.20260728140533')).toBe(false) + expect(isDailyVersion('1.4.160-hourly.202607281400')).toBe(false) + expect(isHourlyVersion('1.4.160-daily.202607281300')).toBe(false) + expect(isDailyVersion('1.4.160-adhoc.20260728140533')).toBe(false) // A 12-digit adhoc tail is an hourly stamp wearing the wrong identifier, not // a second-resolution one; rejecting it keeps the parse unambiguous. expect(isAdhocVersion('1.4.160-adhoc.202607281405')).toBe(false) @@ -118,13 +138,16 @@ describe('release channel', () => { expect(parseAdhocVersionStamp('not-a-version-adhoc.20260101000000')).toBeNull() }) - // Why one entry point for both: the picker renders a row without knowing which + // Why one entry point for all: the picker renders a row without knowing which // dev channel produced it, so a channel added without a case here would fall // back to showing its raw opaque timestamp tail. - it('reads the build timestamp of either dev channel', () => { + it('reads the build timestamp of any dev channel', () => { expect(parseDevBuildStamp('1.4.160-hourly.202607281405')?.toISOString()).toBe( '2026-07-28T14:05:00.000Z' ) + expect(parseDevBuildStamp('1.4.160-daily.202607281300')?.toISOString()).toBe( + '2026-07-28T13:00:00.000Z' + ) expect(parseDevBuildStamp('1.4.160-adhoc.20260728140533')?.toISOString()).toBe( '2026-07-28T14:05:33.000Z' ) @@ -132,11 +155,11 @@ describe('release channel', () => { expect(parseDevBuildStamp('1.4.160')).toBeNull() }) - // Why: both dev workflows are macOS-only, so the channels have no artifact to + // Why: all dev workflows are macOS-only, so the channels have no artifact to // offer elsewhere. Both the picker and the main-process check read this, so a // regression here would silently re-expose an uninstallable channel. it('offers the dev channels only on macOS', () => { - for (const channel of ['hourly', 'adhoc'] as const) { + for (const channel of ['hourly', 'daily', 'adhoc'] as const) { expect(isChannelSupportedOnPlatform(channel, 'darwin')).toBe(true) expect(isChannelSupportedOnPlatform(channel, 'linux')).toBe(false) expect(isChannelSupportedOnPlatform(channel, 'win32')).toBe(false) @@ -152,6 +175,7 @@ describe('release channel', () => { it('accepts only known channels', () => { expect(isReleaseChannel('hourly')).toBe(true) + expect(isReleaseChannel('daily')).toBe(true) expect(isReleaseChannel('adhoc')).toBe(true) expect(isReleaseChannel('stable')).toBe(true) expect(isReleaseChannel('nightly')).toBe(false) @@ -188,6 +212,13 @@ describe('release channel', () => { expect(compareAppVersions('1.4.160-hourly.202607281400', '1.4.160')).toBeLessThan(0) }) + it('orders a daily below its own stable release and below hourly of the same base', () => { + expect(compareAppVersions('1.4.160-daily.202607281300', '1.4.160')).toBeLessThan(0) + expect( + compareAppVersions('1.4.160-daily.202607281300', '1.4.160-hourly.202607281400') + ).toBeLessThan(0) + }) + // Why adhoc sits at the very bottom: it is an unlanded branch, the least // trustworthy thing the updater can hand anyone. Every other channel of the // same base version must outrank it so no routine check ever selects one. @@ -196,6 +227,7 @@ describe('release channel', () => { expect(compareAppVersions(adhoc, '1.4.160')).toBeLessThan(0) expect(compareAppVersions(adhoc, '1.4.160-rc.1')).toBeLessThan(0) expect(compareAppVersions(adhoc, '1.4.160-hourly.202607280000')).toBeLessThan(0) + expect(compareAppVersions(adhoc, '1.4.160-daily.202607281300')).toBeLessThan(0) }) it('sorts consecutive adhoc builds newest first', () => { diff --git a/src/shared/release-channel.ts b/src/shared/release-channel.ts index 743ef6529f3..74b08f8eea5 100644 --- a/src/shared/release-channel.ts +++ b/src/shared/release-channel.ts @@ -1,13 +1,20 @@ import { compareAppVersions, isValidAppVersion } from './app-version' -export type ReleaseChannel = 'stable' | 'rc' | 'hourly' | 'adhoc' +export type ReleaseChannel = 'stable' | 'rc' | 'hourly' | 'daily' | 'adhoc' -export const RELEASE_CHANNELS: readonly ReleaseChannel[] = ['stable', 'rc', 'hourly', 'adhoc'] +export const RELEASE_CHANNELS: readonly ReleaseChannel[] = [ + 'stable', + 'rc', + 'hourly', + 'daily', + 'adhoc' +] export const RELEASE_CHANNEL_LABELS: Readonly> = { stable: 'Stable', rc: 'RC', hourly: 'Hourly', + daily: 'Daily', adhoc: 'Adhoc' } @@ -15,14 +22,16 @@ export const RELEASE_CHANNEL_LABELS: Readonly> = * releases atom feed, which only exposes the 10 newest entries — 24 hourly * tags a day would evict every stable/RC entry and strand real users. */ export const HOURLY_RELEASE_REPO = 'stablyai/orca-hourly' +export const DAILY_RELEASE_REPO = 'stablyai/orca-daily' export const ADHOC_RELEASE_REPO = 'stablyai/orca-adhoc' export const MAIN_RELEASE_REPO = 'stablyai/orca' export const HOURLY_PRERELEASE_IDENTIFIER = 'hourly' +export const DAILY_PRERELEASE_IDENTIFIER = 'daily' export const ADHOC_PRERELEASE_IDENTIFIER = 'adhoc' /** The dev channels, each published to its own repo rather than the main one. */ -const DEDICATED_REPO_CHANNELS = ['hourly', 'adhoc'] as const +const DEDICATED_REPO_CHANNELS = ['hourly', 'daily', 'adhoc'] as const export type DedicatedRepoChannel = (typeof DEDICATED_REPO_CHANNELS)[number] @@ -30,6 +39,7 @@ const CHANNEL_RELEASE_REPOS: Record = { stable: MAIN_RELEASE_REPO, rc: MAIN_RELEASE_REPO, hourly: HOURLY_RELEASE_REPO, + daily: DAILY_RELEASE_REPO, adhoc: ADHOC_RELEASE_REPO } @@ -47,8 +57,8 @@ export function hasDedicatedReleaseRepo(channel: ReleaseChannel): channel is Ded * Shared so the picker, the main-process check, and any future surface cannot * drift on where a channel is available. * - * Why this rides on the dev-channel list: both dev channels are produced only by - * macOS workflows, so neither has an artifact to offer elsewhere. If one ever + * Why this rides on the dev-channel list: all dev channels are produced only by + * macOS workflows, so none has an artifact to offer elsewhere. If one ever * gains a Windows or Linux job, split the two concepts apart — they coincide * today, but "published to its own repo" and "built for macOS only" are not the * same claim. @@ -72,14 +82,19 @@ export function normalizeTagToVersion(tag: string): string { * uniquely versioned so electron-updater never reads one as "same version". */ const HOURLY_VERSION = /^\d+\.\d+\.\d+-hourly\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})$/ +/** `1.4.160-daily.202607281415` — same minute stamp as hourly. Daily cuts once + * per day, so collisions are not a concern; the stamp still carries the hour so + * a forced re-cut the same calendar day remains unique. */ +const DAILY_VERSION = /^\d+\.\d+\.\d+-daily\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})$/ + /** * `1.4.160-adhoc.20260728140533` — same idea, but stamped to the second. * - * Why seconds here and not for hourly: hourly runs under a concurrency group, so - * two of them can never be cut in the same minute. Adhoc builds are dispatched - * on demand by whoever wants one, so two people cutting from different branches - * at once is ordinary — and a minute-resolution stamp would collide on the tag - * and fail the second build eight minutes in. + * Why seconds here and not for hourly/daily: those run under a concurrency + * group, so two of them can never be cut in the same minute. Adhoc builds are + * dispatched on demand by whoever wants one, so two people cutting from + * different branches at once is ordinary — and a minute-resolution stamp would + * collide on the tag and fail the second build eight minutes in. */ const ADHOC_VERSION = /^\d+\.\d+\.\d+-adhoc\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/ @@ -113,6 +128,10 @@ export function isHourlyVersion(version: string): boolean { return HOURLY_VERSION.test(normalizeTagToVersion(version)) } +export function isDailyVersion(version: string): boolean { + return DAILY_VERSION.test(normalizeTagToVersion(version)) +} + export function isAdhocVersion(version: string): boolean { return ADHOC_VERSION.test(normalizeTagToVersion(version)) } @@ -121,6 +140,10 @@ export function formatHourlyVersion(baseVersion: string, stamp: string): string return `${baseVersion}-${HOURLY_PRERELEASE_IDENTIFIER}.${stamp}` } +export function formatDailyVersion(baseVersion: string, stamp: string): string { + return `${baseVersion}-${DAILY_PRERELEASE_IDENTIFIER}.${stamp}` +} + export function formatAdhocVersion(baseVersion: string, stamp: string): string { return `${baseVersion}-${ADHOC_PRERELEASE_IDENTIFIER}.${stamp}` } @@ -130,15 +153,24 @@ export function parseHourlyVersionStamp(version: string): Date | null { return parseStampedVersion(version, HOURLY_VERSION) } +/** Returns the build's UTC timestamp, or null when the version isn't daily. */ +export function parseDailyVersionStamp(version: string): Date | null { + return parseStampedVersion(version, DAILY_VERSION) +} + /** Returns the build's UTC timestamp, or null when the version isn't adhoc. */ export function parseAdhocVersionStamp(version: string): Date | null { return parseStampedVersion(version, ADHOC_VERSION) } -/** The build's UTC timestamp for either dev channel, so a picker row can render - * a date without first working out which channel produced the version. */ +/** The build's UTC timestamp for any dev channel, so a picker row can render a + * date without first working out which channel produced the version. */ export function parseDevBuildStamp(version: string): Date | null { - return parseHourlyVersionStamp(version) ?? parseAdhocVersionStamp(version) + return ( + parseHourlyVersionStamp(version) ?? + parseDailyVersionStamp(version) ?? + parseAdhocVersionStamp(version) + ) } export function getVersionChannel(version: string): ReleaseChannel | null { @@ -149,6 +181,9 @@ export function getVersionChannel(version: string): ReleaseChannel | null { if (isHourlyVersion(normalized)) { return 'hourly' } + if (isDailyVersion(normalized)) { + return 'daily' + } if (isAdhocVersion(normalized)) { return 'adhoc' } From 8a33ce2536e7b478ae234bc5888a5cb383da2f4c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:04:05 -0700 Subject: [PATCH 015/271] [leas code] Simplify boolean guards (#13401) * refactor: remove unreachable code * refactor: simplify boolean guards --- .../right-sidebar-activity-visibility.ts | 18 ++++++------------ .../terminal/active-terminal-repair.ts | 16 +++++----------- .../src/lib/new-workspace-enter-guard.ts | 15 +-------------- .../pane-manager/windows-pty-compatibility.ts | 16 ++++++---------- .../src/lib/passive-macos-app-data-access.ts | 19 +++++++------------ 5 files changed, 25 insertions(+), 59 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts b/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts index bcb65fa6123..f4bc0958959 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts +++ b/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts @@ -10,16 +10,10 @@ export function getVisibleRightSidebarActivityItems( items: ActivityBarItem[], { isFolder, isFolderWorkspace, isSshRepo }: RightSidebarActivityVisibilityState ): ActivityBarItem[] { - return items.filter((item) => { - if (item.gitOnly && isFolder) { - return false - } - if (item.folderOnly && !isFolderWorkspace) { - return false - } - if (item.sshOnly && !isSshRepo) { - return false - } - return true - }) + return items.filter( + (item) => + (!item.gitOnly || !isFolder) && + (!item.folderOnly || isFolderWorkspace) && + (!item.sshOnly || isSshRepo) + ) } diff --git a/src/renderer/src/components/terminal/active-terminal-repair.ts b/src/renderer/src/components/terminal/active-terminal-repair.ts index 86a491775bc..81dc5b2c6d3 100644 --- a/src/renderer/src/components/terminal/active-terminal-repair.ts +++ b/src/renderer/src/components/terminal/active-terminal-repair.ts @@ -5,19 +5,13 @@ export function shouldRepairActiveTerminalTab(args: { activeTabId: string | null tabs: TerminalTab[] }): boolean { - if (args.activeTabType !== 'terminal') { - return false - } - if (args.tabs.length === 0) { - return false - } - if (args.activeTabId && args.tabs.some((tab) => tab.id === args.activeTabId)) { - return false - } - return true + return ( + args.activeTabType === 'terminal' && + args.tabs.length > 0 && + (!args.activeTabId || !args.tabs.some((tab) => tab.id === args.activeTabId)) + ) } -// Resolve which terminal tab to open after a project/agent is selected, or null if no repair is needed. export function resolveRepairedActiveTerminalTabId(args: { activeTabType: WorkspaceVisibleTabType activeTabId: string | null diff --git a/src/renderer/src/lib/new-workspace-enter-guard.ts b/src/renderer/src/lib/new-workspace-enter-guard.ts index 0359a4892a5..3993ada5055 100644 --- a/src/renderer/src/lib/new-workspace-enter-guard.ts +++ b/src/renderer/src/lib/new-workspace-enter-guard.ts @@ -1,21 +1,8 @@ -/** - * Returns true when an Enter keydown event should be suppressed for submit actions. - * - * Two cases must be blocked: - * 1. IME composition is active — Enter only confirms the conversion candidate. - * 2. Shift+Enter inside a textarea — intended as a newline, not a submit. - */ export function shouldSuppressEnterSubmit( event: { isComposing: boolean; shiftKey: boolean }, isTextarea: boolean ): boolean { - if (event.isComposing) { - return true - } - if (isTextarea && event.shiftKey) { - return true - } - return false + return event.isComposing || (isTextarea && event.shiftKey) } export function shouldAllowComposerEnterSubmitTarget( diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts index 5dd0349507c..6b5c9141b0b 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts @@ -84,16 +84,12 @@ export function resolveWindowsShellOverride( * local pane from a serve pane, so callers gate it with `isLocalNativeWindowsConpty`. */ export function isLocalNativeWindowsPty(context: WindowsPtyCompatibilityContext): boolean { - if (!isWindowsUserAgent(context.userAgent)) { - return false - } - if (context.connectionId !== null) { - return false - } - if (isWslCwd(context.cwd) || isWslShellOverride(context.shellOverride)) { - return false - } - return true + return ( + isWindowsUserAgent(context.userAgent) && + context.connectionId === null && + !isWslCwd(context.cwd) && + !isWslShellOverride(context.shellOverride) + ) } /** diff --git a/src/renderer/src/lib/passive-macos-app-data-access.ts b/src/renderer/src/lib/passive-macos-app-data-access.ts index 5be004d612e..994db53c242 100644 --- a/src/renderer/src/lib/passive-macos-app-data-access.ts +++ b/src/renderer/src/lib/passive-macos-app-data-access.ts @@ -34,18 +34,13 @@ export function hasInteractiveActiveGitStatusConsumer(args: ActiveGitStatusPolli if (!args.activeWorktreeId || !args.worktreePath) { return false } - if ( - args.rightSidebarOpen && - (args.rightSidebarTab === 'source-control' || - (args.rightSidebarTab === 'explorer' && args.rightSidebarExplorerView !== 'search') || - args.rightSidebarTab === 'checks') - ) { - return true - } - if ((args.openFiles ?? []).some((file) => file.worktreeId === args.activeWorktreeId)) { - return true - } - return false + return ( + (args.rightSidebarOpen && + (args.rightSidebarTab === 'source-control' || + (args.rightSidebarTab === 'explorer' && args.rightSidebarExplorerView !== 'search') || + args.rightSidebarTab === 'checks')) || + (args.openFiles ?? []).some((file) => file.worktreeId === args.activeWorktreeId) + ) } export function shouldPollActiveGitStatus(args: ActiveGitStatusPollingArgs): boolean { From f3ea376b6b26695d58e340ee9ca33a3cc8e9f90d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:06:07 -0700 Subject: [PATCH 016/271] refactor(rate-limits): share initial account target policy (#13428) --- .../claude-rate-limit-target.test.ts | 179 ------------------ .../rate-limits/claude-rate-limit-target.ts | 68 +------ .../codex-rate-limit-target.test.ts | 179 ------------------ .../rate-limits/codex-rate-limit-target.ts | 67 +------ .../initial-account-rate-limit-target.test.ts | 163 ++++++++++++++++ .../initial-account-rate-limit-target.ts | 80 ++++++++ .../project-runtime-rate-limit-target.ts | 29 --- 7 files changed, 258 insertions(+), 507 deletions(-) delete mode 100644 src/main/rate-limits/claude-rate-limit-target.test.ts delete mode 100644 src/main/rate-limits/codex-rate-limit-target.test.ts create mode 100644 src/main/rate-limits/initial-account-rate-limit-target.test.ts create mode 100644 src/main/rate-limits/initial-account-rate-limit-target.ts delete mode 100644 src/main/rate-limits/project-runtime-rate-limit-target.ts diff --git a/src/main/rate-limits/claude-rate-limit-target.test.ts b/src/main/rate-limits/claude-rate-limit-target.test.ts deleted file mode 100644 index 969124655ad..00000000000 --- a/src/main/rate-limits/claude-rate-limit-target.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -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('ignores stale terminal WSL distro when account runtime is WSL default', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: null, - terminalWindowsWslDistro: 'Debian', - activeClaudeManagedAccountIdsByRuntime: { - host: 'host-account-1', - wsl: {} - } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: null }) - }) - - it('uses the global WSL project runtime default when account runtime is unset', () => { - expect( - getInitialClaudeRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, - localAgentRuntime: 'host', - terminalWindowsWslDistro: 'Debian' - }), - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('ignores stale legacy WSL agent and terminal settings when the project default is host', () => { - expect( - getInitialClaudeRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - localWindowsRuntimeDefault: { kind: 'windows-host' }, - localAgentRuntime: 'wsl', - localAgentWslDistro: 'Ubuntu', - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Debian' - }), - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - 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('auto follows the global WSL project runtime default', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('auto resolves to host when the global project runtime is windows-host', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'windows-host' } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('does not let a stale WSL-only selection override an auto host target', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'windows-host' }, - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'wsl-account-1' } - } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - 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' }) - }) - - it('ignores a stale explicit WSL runtime on non-Windows hosts', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Ubuntu' - }, - 'linux' - ) - ).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 index 199250cd3e5..dba2e396669 100644 --- a/src/main/rate-limits/claude-rate-limit-target.ts +++ b/src/main/rate-limits/claude-rate-limit-target.ts @@ -1,71 +1,21 @@ import type { GlobalSettings } from '../../shared/types' -import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import { getClaudeWslSelectionKey, normalizeClaudeRuntimeSelection, type ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' -import { - getProjectRuntimeRateLimitTarget, - normalizeOptionalDistro -} from './project-runtime-rate-limit-target' - -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 -} +import { getInitialAccountRateLimitTarget } from './initial-account-rate-limit-target' export function getInitialClaudeRateLimitTarget( settings: GlobalSettings, platform: NodeJS.Platform = process.platform ): ClaudeAccountSelectionTarget { - if (settings.localAccountRuntime === 'host') { - return { runtime: 'host' } - } - if (settings.localAccountRuntime === 'wsl') { - if (platform !== 'win32') { - return { runtime: 'host' } - } - return { - runtime: 'wsl', - wslDistro: - normalizeOptionalDistro(settings.localAccountWslDistro) ?? - getSingleSelectedWslDistro(settings) - } - } - if (settings.localAccountRuntime === 'auto') { - const target = resolveLocalAccountRuntimeTarget(settings, platform) - return target.runtime === 'wsl' - ? { runtime: 'wsl', wslDistro: target.wslDistro } - : { runtime: 'host' } - } - - // Why: pre-setting profiles used account selection as their startup fallback. - const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform) - if (projectRuntimeTarget) { - return projectRuntimeTarget - } - - 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' } + return getInitialAccountRateLimitTarget( + settings, + { + getWslSelectionKey: getClaudeWslSelectionKey, + normalizeRuntimeSelection: normalizeClaudeRuntimeSelection + }, + platform + ) } diff --git a/src/main/rate-limits/codex-rate-limit-target.test.ts b/src/main/rate-limits/codex-rate-limit-target.test.ts deleted file mode 100644 index b3b39f027f9..00000000000 --- a/src/main/rate-limits/codex-rate-limit-target.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -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('ignores stale terminal WSL distro when account runtime is WSL default', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: null, - terminalWindowsWslDistro: 'Debian', - activeCodexManagedAccountIdsByRuntime: { - host: 'host-account-1', - wsl: {} - } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: null }) - }) - - it('uses the global WSL project runtime default when account runtime is unset', () => { - expect( - getInitialCodexRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, - localAgentRuntime: 'host', - terminalWindowsWslDistro: 'Debian' - }), - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('ignores stale legacy WSL agent and terminal settings when the project default is host', () => { - expect( - getInitialCodexRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - localWindowsRuntimeDefault: { kind: 'windows-host' }, - localAgentRuntime: 'wsl', - localAgentWslDistro: 'Ubuntu', - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Debian' - }), - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - 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('auto follows the global WSL project runtime default', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('auto resolves to host when the global project runtime is windows-host', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'windows-host' } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('does not let a stale WSL-only selection override an auto host target', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'windows-host' }, - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'wsl-account-1' } - } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - 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' }) - }) - - it('ignores a stale explicit WSL runtime on non-Windows hosts', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Ubuntu' - }, - 'darwin' - ) - ).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 index 65c459ad7d3..61c116bab2b 100644 --- a/src/main/rate-limits/codex-rate-limit-target.ts +++ b/src/main/rate-limits/codex-rate-limit-target.ts @@ -1,73 +1,18 @@ import type { GlobalSettings } from '../../shared/types' -import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import { getWslSelectionKey, normalizeCodexRuntimeSelection, type CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' -import { - getProjectRuntimeRateLimitTarget, - normalizeOptionalDistro -} from './project-runtime-rate-limit-target' - -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 -} +import { getInitialAccountRateLimitTarget } from './initial-account-rate-limit-target' export function getInitialCodexRateLimitTarget( settings: GlobalSettings, platform: NodeJS.Platform = process.platform ): CodexAccountSelectionTarget { - if (settings.localAccountRuntime === 'host') { - return { runtime: 'host' } - } - if (settings.localAccountRuntime === 'wsl') { - if (platform !== 'win32') { - return { runtime: 'host' } - } - return { - runtime: 'wsl', - wslDistro: - normalizeOptionalDistro(settings.localAccountWslDistro) ?? - getSingleSelectedWslDistro(settings) - } - } - if (settings.localAccountRuntime === 'auto') { - const target = resolveLocalAccountRuntimeTarget(settings, platform) - return target.runtime === 'wsl' - ? { runtime: 'wsl', wslDistro: target.wslDistro } - : { runtime: 'host' } - } - - // Why: pre-setting profiles used account selection as their startup fallback. - const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform) - if (projectRuntimeTarget) { - return projectRuntimeTarget - } - - 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' } + return getInitialAccountRateLimitTarget( + settings, + { getWslSelectionKey, normalizeRuntimeSelection: normalizeCodexRuntimeSelection }, + platform + ) } diff --git a/src/main/rate-limits/initial-account-rate-limit-target.test.ts b/src/main/rate-limits/initial-account-rate-limit-target.test.ts new file mode 100644 index 00000000000..528a7527968 --- /dev/null +++ b/src/main/rate-limits/initial-account-rate-limit-target.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/types' +import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target' +import { getInitialCodexRateLimitTarget } from './codex-rate-limit-target' + +type RuntimeSelection = { + host: string | null + wsl: Record +} + +type Scenario = { + name: string + settings: Partial + expected: { runtime: 'host' } | { runtime: 'wsl'; wslDistro: string | null } + legacy?: boolean + platform?: NodeJS.Platform + selection?: RuntimeSelection +} + +const providers = [ + { + name: 'Codex', + getTarget: getInitialCodexRateLimitTarget, + selectionSettings: (selection: RuntimeSelection) => ({ + activeCodexManagedAccountIdsByRuntime: selection + }) + }, + { + name: 'Claude', + getTarget: getInitialClaudeRateLimitTarget, + selectionSettings: (selection: RuntimeSelection) => ({ + activeClaudeManagedAccountIdsByRuntime: selection + }) + } +] as const + +const scenarios: Scenario[] = [ + { + name: 'uses the trimmed configured WSL distro before the selected account', + settings: { + localAccountRuntime: 'wsl', + localAccountWslDistro: ' Fedora ', + localAgentRuntime: 'host', + terminalWindowsWslDistro: 'Debian' + }, + selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } }, + expected: { runtime: 'wsl', wslDistro: 'Fedora' } + }, + { + name: 'uses the single selected distro for an unpinned WSL runtime', + settings: { localAccountRuntime: 'wsl' }, + selection: { host: 'host-account-1', wsl: { Ubuntu: 'wsl-account-1' } }, + expected: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }, + { + name: 'ignores a stale terminal distro for an unpinned WSL runtime', + settings: { + localAccountRuntime: 'wsl', + localAccountWslDistro: null, + terminalWindowsWslDistro: 'Debian' + }, + selection: { host: 'host-account-1', wsl: {} }, + expected: { runtime: 'wsl', wslDistro: null } + }, + { + name: 'keeps an explicit host runtime on host', + settings: { localAccountRuntime: 'host', terminalWindowsShell: 'wsl.exe' }, + selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } }, + expected: { runtime: 'host' } + }, + { + name: 'ignores an explicit WSL runtime on non-Windows hosts', + settings: { localAccountRuntime: 'wsl', localAccountWslDistro: 'Ubuntu' }, + platform: 'linux', + expected: { runtime: 'host' } + }, + { + name: 'auto follows the global WSL runtime before stale selections', + settings: { + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }, + selection: { host: null, wsl: { Debian: 'wsl-account-1' } }, + expected: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }, + { + name: 'auto keeps the global host runtime before stale selections', + settings: { + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'windows-host' } + }, + selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } }, + expected: { runtime: 'host' } + }, + { + name: 'legacy settings use the global WSL runtime before selections', + settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } }, + selection: { host: null, wsl: { Debian: 'wsl-account-1' } }, + legacy: true, + expected: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }, + { + name: 'legacy settings ignore stale agent and terminal WSL values', + settings: { + localWindowsRuntimeDefault: { kind: 'windows-host' }, + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu', + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }, + legacy: true, + expected: { runtime: 'host' } + }, + { + name: 'legacy settings preserve a single WSL selection on macOS', + settings: {}, + selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } }, + legacy: true, + platform: 'darwin', + expected: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }, + { + name: 'legacy settings map the provider default WSL key to no distro', + settings: {}, + selection: { host: null, wsl: { __default__: 'wsl-account-1' } }, + legacy: true, + platform: 'linux', + expected: { runtime: 'wsl', wslDistro: null } + }, + { + name: 'legacy selection fallback requires no selected host account', + settings: {}, + selection: { host: 'host-account-1', wsl: { Ubuntu: 'wsl-account-1' } }, + legacy: true, + expected: { runtime: 'host' } + }, + { + name: 'legacy selection fallback requires exactly one selected WSL account', + settings: {}, + selection: { + host: null, + wsl: { Debian: 'wsl-account-1', Fedora: null, Ubuntu: 'wsl-account-2' } + }, + legacy: true, + expected: { runtime: 'host' } + } +] + +describe.each(providers)('$name initial rate-limit target', ({ getTarget, selectionSettings }) => { + it.each(scenarios)('$name', (scenario) => { + const settings = { + ...getDefaultSettings('/tmp'), + ...scenario.settings, + ...(scenario.selection ? selectionSettings(scenario.selection) : {}) + } + if (scenario.legacy) { + delete (settings as Partial).localAccountRuntime + } + + expect(getTarget(settings, scenario.platform ?? 'win32')).toEqual(scenario.expected) + }) +}) diff --git a/src/main/rate-limits/initial-account-rate-limit-target.ts b/src/main/rate-limits/initial-account-rate-limit-target.ts new file mode 100644 index 00000000000..d60dab69daf --- /dev/null +++ b/src/main/rate-limits/initial-account-rate-limit-target.ts @@ -0,0 +1,80 @@ +import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' +import type { GlobalSettings } from '../../shared/types' + +type AccountRuntimeSelection = { + host: string | null + wsl: Record +} + +type AccountRuntimeSelectionProvider = { + getWslSelectionKey: (wslDistro: string | null | undefined) => string + normalizeRuntimeSelection: (settings: GlobalSettings) => AccountRuntimeSelection +} + +type WslRateLimitRuntimeTarget = { runtime: 'wsl'; wslDistro: string | null } + +export type AccountRateLimitRuntimeTarget = { runtime: 'host' } | WslRateLimitRuntimeTarget + +export function getInitialAccountRateLimitTarget( + settings: GlobalSettings, + provider: AccountRuntimeSelectionProvider, + platform: NodeJS.Platform = process.platform +): AccountRateLimitRuntimeTarget { + if (settings.localAccountRuntime === 'host') { + return { runtime: 'host' } + } + + if (settings.localAccountRuntime === 'wsl') { + if (platform !== 'win32') { + return { runtime: 'host' } + } + const configuredTarget = resolveLocalAccountRuntimeTarget(settings, platform) + return { + runtime: 'wsl', + wslDistro: + configuredTarget.wslDistro ?? + getSingleSelectedWslTarget( + provider.normalizeRuntimeSelection(settings), + provider.getWslSelectionKey + )?.wslDistro ?? + null + } + } + + // Pre-setting profiles omit the policy and fall back to project/account selection. + const resolvedTarget = toRateLimitTarget(resolveLocalAccountRuntimeTarget(settings, platform)) + if (settings.localAccountRuntime === 'auto' || resolvedTarget.runtime === 'wsl') { + return resolvedTarget + } + + const selection = provider.normalizeRuntimeSelection(settings) + return selection.host + ? { runtime: 'host' } + : (getSingleSelectedWslTarget(selection, provider.getWslSelectionKey) ?? { runtime: 'host' }) +} + +function getSingleSelectedWslTarget( + selection: AccountRuntimeSelection, + getWslSelectionKey: AccountRuntimeSelectionProvider['getWslSelectionKey'] +): WslRateLimitRuntimeTarget | null { + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length !== 1) { + return null + } + + const [distroKey] = selectedWslEntries[0] + return { + runtime: 'wsl', + wslDistro: distroKey === getWslSelectionKey(null) ? null : distroKey + } +} + +function toRateLimitTarget( + target: ReturnType +): AccountRateLimitRuntimeTarget { + return target.runtime === 'wsl' + ? { runtime: 'wsl', wslDistro: target.wslDistro } + : { runtime: 'host' } +} diff --git a/src/main/rate-limits/project-runtime-rate-limit-target.ts b/src/main/rate-limits/project-runtime-rate-limit-target.ts deleted file mode 100644 index b6504235aa3..00000000000 --- a/src/main/rate-limits/project-runtime-rate-limit-target.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { GlobalSettings } from '../../shared/types' -import { normalizeGlobalWindowsRuntimeDefault } from '../../shared/project-execution-runtime' - -export type AccountRateLimitRuntimeTarget = - | { runtime: 'host' } - | { runtime: 'wsl'; wslDistro: string | null } - -export function getProjectRuntimeRateLimitTarget( - settings: GlobalSettings, - platform: NodeJS.Platform -): AccountRateLimitRuntimeTarget | null { - if (platform !== 'win32') { - return null - } - - const runtimeDefault = normalizeGlobalWindowsRuntimeDefault(settings.localWindowsRuntimeDefault) - if (runtimeDefault.kind !== 'wsl') { - return null - } - - // Why: account quota polling has no project id, so its best default is the - // global project runtime instead of stale terminal/agent WSL settings. - return { runtime: 'wsl', wslDistro: runtimeDefault.distro } -} - -export function normalizeOptionalDistro(value: string | null | undefined): string | null { - const trimmed = value?.trim() - return trimmed ? trimmed : null -} From a3ca887a3c08439a89c9ce7db95f480ef29c5384 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:09:49 -0700 Subject: [PATCH 017/271] fix(workspaces): start task agents on remote hosts (#13412) --- ...poserState-host-context-boundaries.test.ts | 2 ++ src/renderer/src/hooks/useComposerState.ts | 6 +++- .../src/lib/worktree-activation.test.ts | 32 +++++++++++++++++ src/renderer/src/lib/worktree-activation.ts | 28 +++++++++++---- .../src/lib/worktree-creation-flow.test.ts | 26 ++++++++++---- .../src/lib/worktree-creation-flow.ts | 13 +++++-- .../src/store/slices/worktree-helpers.ts | 2 ++ .../src/store/slices/worktrees.test.ts | 36 +++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 2 ++ 9 files changed, 129 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index f7aa99c8201..635399b63d8 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -750,6 +750,7 @@ describe('useComposerState host-context boundaries', () => { 'const submitQuick = useCallback' ) expect(fullSubmit).toContain('platform: selectedRepoAgentLaunchPlatform') + expect(fullSubmit).toContain('startupDraft: startupPlan.draftPrompt') expect(fullSubmit).not.toContain('platform: CLIENT_PLATFORM') const quickSubmit = sourceBetween( @@ -771,6 +772,7 @@ describe('useComposerState host-context boundaries', () => { ) expect(activation).toContain('...(startupPlan && !backendSpawnedStartup') + expect(activation).toContain('backendStartupTerminalSpawned: true') expect(activation).toContain('command: startupPlan.launchCommand') expect(activation).toContain('launchAgent: tuiAgent') // The removed activation-time fallback must not come back through this caller. diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 2dcfe314f3e..35f403c8e09 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -3849,7 +3849,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS submitCompareBaseRef, { linkedWorkItem: toFolderWorkspaceLinkedTask(submitLinkedWorkItem), - linkedTaskSourceContext: taskSourceContext + linkedTaskSourceContext: taskSourceContext, + ...(!backendStartup && startupPlan?.draftPrompt + ? { startupDraft: startupPlan.draftPrompt } + : {}) } ) const worktree = result.worktree @@ -3877,6 +3880,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setup: result.setup, defaultTabs: result.defaultTabs, issueCommand, + ...(backendSpawnedStartup ? { backendStartupTerminalSpawned: true } : {}), ...(startupPlan && !backendSpawnedStartup ? { startup: { diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index cdebf649389..923cecd5aed 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -370,6 +370,38 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) + it('does not create a fallback while a backend startup terminal awaits mirroring', () => { + useAppStore.setState({ + getKnownWorktreeById: ((id: string) => + id === 'wt-1' + ? { id: 'wt-1' } + : undefined) as unknown as AppStoreState['getKnownWorktreeById'] + } as Partial) + const store = createMockStore() + + const result = ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + undefined, + undefined, + { command: 'gh issue view 42' }, + undefined, + { backendStartupTerminalSpawned: true } + ) + + expect(result).toBeNull() + expect(store.createTab).not.toHaveBeenCalled() + expect(store.setActiveTab).not.toHaveBeenCalled() + + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] } + } as unknown as Partial) + + expect(useAppStore.getState().pendingIssueCommandSplitByTabId['mirror-tab-1']).toEqual({ + command: 'gh issue view 42' + }) + }) + it('creates a local initial terminal for explicitly local worktrees while a runtime is focused', () => { useAppStore.setState((state) => ({ settings: state.settings diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 7e317024123..d27a18565ef 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -171,6 +171,11 @@ type WorktreeActivationStore = Partial & { settings?: Pick | null } +type InitialTerminalOptions = { + activateCreatedTabs?: boolean + backendStartupTerminalSpawned?: boolean +} + /** * Shared activation sequence used by the worktree palette and add-repo/worktree dialogs. * The caller passes only `worktreeId`; the helper derives `repoId` and returns early @@ -279,6 +284,7 @@ export function activateAndRevealWorktree( notifyHostRuntime?: boolean revealInSidebar?: boolean executionHostId?: ExecutionHostId + backendStartupTerminalSpawned?: boolean } ): ActivateAndRevealResult | false { const state = useAppStore.getState() @@ -339,7 +345,8 @@ export function activateAndRevealWorktree( opts?.startup, opts?.setup, opts?.issueCommand, - opts?.defaultTabs + opts?.defaultTabs, + opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : undefined ) if (primaryTabId && opts?.initialCwd) { useAppStore.getState().queueTabInitialCwd(primaryTabId, opts.initialCwd) @@ -371,7 +378,7 @@ export function activateAndRevealWorktree( } } - if (opts?.notifyHostRuntime !== false) { + if (opts?.notifyHostRuntime !== false && !opts?.backendStartupTerminalSpawned) { ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId) } @@ -432,7 +439,7 @@ export function ensureWorktreeHasInitialTerminal( setup?: WorktreeSetupLaunch, issueCommand?: IssueCommandLaunch, defaultTabs?: WorktreeDefaultTabsLaunch, - opts?: { activateCreatedTabs?: boolean } + opts?: InitialTerminalOptions ): string | null { const { renderableTabCount } = store.reconcileWorktreeTabModel(worktreeId) // Why: creating a terminal just because the legacy terminal slice is empty gives editor/browser-only worktrees an unexpected extra tab. @@ -459,8 +466,12 @@ export function ensureWorktreeHasInitialTerminal( wrappedSetupCommandStr = sequenced.setupCommand } - // Why: web clients mirror the server's session tabs, so avoid spawning a duplicate host terminal before the mirror lands. - if (isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(ownerState, worktreeId))) { + const backendStartupTerminalSpawned = opts?.backendStartupTerminalSpawned === true + // Why: explicit spawn evidence survives the new-worktree ownership race; active web sessions provide the same authority for later activations. + if ( + backendStartupTerminalSpawned || + isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(ownerState, worktreeId)) + ) { const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id if (existingTerminalTabId && (setup || issueCommand)) { queueSetupAndIssueCommands( @@ -474,6 +485,9 @@ export function ensureWorktreeHasInitialTerminal( ) return existingTerminalTabId } + if (existingTerminalTabId && backendStartupTerminalSpawned) { + return existingTerminalTabId + } if (setup || issueCommand) { // Why: runtime-owned worktrees mirror session tabs async, so hold commands for the first mirrored tab instead of dropping them. queueHookCommandsForFirstWorktreeTab({ @@ -586,7 +600,7 @@ function applyDefaultTerminalTabs( issueCommand: IssueCommandLaunch | undefined, defaultTabs: WorktreeDefaultTabsLaunch | undefined, wrappedSetupCommandStr: string | undefined, - opts: { activateCreatedTabs?: boolean } | undefined + opts: InitialTerminalOptions | undefined ): string | null { if (!defaultTabs || store.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { return null @@ -675,7 +689,7 @@ function queueSetupAndIssueCommands( setup: WorktreeSetupLaunch | undefined, issueCommand: IssueCommandLaunch | undefined, wrappedSetupCommandStr: string | undefined, - opts: { activateCreatedTabs?: boolean } | undefined + opts: InitialTerminalOptions | undefined ): void { // Why: setup launch location is user-configurable — 'new-tab' keeps setup output off the primary pane; splits keep it adjacent. if (setup) { diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index ad24a825076..f48591a8b2f 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -512,13 +512,14 @@ describe('staged background worktree creation', () => { expect(store.setSidebarOpen).not.toHaveBeenCalled() }) - it('does not reveal a completed staged create after the user leaves the creation surface', async () => { + it('keeps a backend startup terminal in the background after the user leaves', async () => { store.activeView = 'tasks' store.createWorktree.mockResolvedValueOnce({ worktree: { id: 'wt-1', repoId: 'repo-1' - } + }, + startupTerminal: { tabId: 'agent-tab', spawned: true } }) const started = continueBackgroundWorktreeCreation('creation-1', makeRequest(), { @@ -535,7 +536,7 @@ describe('staged background worktree creation', () => { undefined, undefined, undefined, - { activateCreatedTabs: false } + { activateCreatedTabs: false, backendStartupTerminalSpawned: true } ) expect(queueWorkspaceActivationTerminalFocus).not.toHaveBeenCalled() expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { @@ -543,8 +544,11 @@ describe('staged background worktree creation', () => { }) }) - it('reveals the completed workspace after the user switches to another workspace', async () => { - let resolveCreate!: (result: { worktree: { id: string; repoId: string } }) => void + it('reveals a backend-owned startup after the user switches workspaces', async () => { + let resolveCreate!: (result: { + worktree: { id: string; repoId: string } + startupTerminal: { tabId: string; spawned: true } + }) => void store.createWorktree.mockReturnValueOnce( new Promise((resolve) => { resolveCreate = resolve @@ -560,11 +564,15 @@ describe('staged background worktree creation', () => { // Why: selecting a real workspace clears only the pending surface pointer; // completion should still finish the task-launch handoff once it is ready. store.activePendingCreationId = null - resolveCreate({ worktree: { id: 'wt-1', repoId: 'repo-1' } }) + resolveCreate({ + worktree: { id: 'wt-1', repoId: 'repo-1' }, + startupTerminal: { tabId: 'agent-tab', spawned: true } + }) await flushAsyncWorktreeCreation() expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { - sidebarRevealBehavior: 'auto' + sidebarRevealBehavior: 'auto', + backendStartupTerminalSpawned: true }) expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { @@ -731,6 +739,10 @@ describe('staged background worktree creation', () => { expect(store.seedNativeChatLaunchDraft).toHaveBeenCalledWith( expect.objectContaining({ tabId: 'agent-tab' }) ) + const createCall = store.createWorktree.mock.calls[0] as unknown[] | undefined + expect(createCall?.[25]).toEqual({ + startupDraft: 'https://github.com/o/r/issues/12' + }) }) it.each([ diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 83451b8ce9f..132f4825dc8 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -140,6 +140,10 @@ async function executeWorktreeCreation( : {}), ...(preparedRequest.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: preparedRequest.linkedTaskSourceContext } + : {}), + // Why: the remote host must own task-draft startup so its initial terminal is the agent, not an idle fallback shell. + ...(!backendStartup && preparedRequest.agent && preparedRequest.launchDraftPrompt + ? { startupDraft: preparedRequest.launchDraftPrompt } : {}) } ) @@ -167,7 +171,6 @@ async function executeWorktreeCreation( } const worktree = result.worktree - // Why: if the user dismissed/cancelled while the create was in flight, the entry // is gone. Git already made the worktree on disk, but don't auto-provision (trust // write, terminal, agent, note) work they abandoned — it surfaces as a plain row @@ -210,7 +213,8 @@ async function executeWorktreeCreation( ...(result.setup ? { setup: result.setup } : {}), ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), ...(startupOpt ? { startup: startupOpt } : {}), - ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}) + ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}), + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) }) primaryTabId = activation === false ? null : activation.primaryTabId } else { @@ -224,7 +228,10 @@ async function executeWorktreeCreation( result.setup, preparedRequest.issueCommand, result.defaultTabs, - { activateCreatedTabs: false } + { + activateCreatedTabs: false, + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) + } ) } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 16a58fd985f..499a4865cfe 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -198,6 +198,8 @@ export type WorktreeSlice = { automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest'] linkedWorkItem?: WorkspaceLinkedItem | null linkedTaskSourceContext?: TaskSourceContext | null + /** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */ + startupDraft?: string } ) => Promise /** Register an in-flight background creation and make it the active surface. */ diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index b13a197b597..6709169daca 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -5913,6 +5913,42 @@ describe('worktree remote runtime mutations', () => { ) }) + it('passes task startup drafts only to the owning remote runtime', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/task-draft', + repoId: 'repo1', + path: '/path/task-draft' + }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-create', + ok: true, + result: { worktree: wt }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [] } + } as Partial) + const createWorktree = store.getState().createWorktree + const args: Parameters = ['repo1', 'task-draft', undefined, 'inherit'] + args[10] = 'codex' + args[25] = { startupDraft: 'https://github.com/stablyai/orca/issues/12' } + + await createWorktree(...args) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'worktree.create', + params: expect.objectContaining({ + createdWithAgent: 'codex', + startupDraft: 'https://github.com/stablyai/orca/issues/12' + }) + }) + ) + expect(mockApi.worktrees.create).not.toHaveBeenCalled() + }) + it('passes startup commands through local worktree creation IPC', async () => { const store = createTestStore() const wt = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index f2efca80185..51613ebdb8a 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3989,6 +3989,7 @@ export const createWorktreeSlice: StateCreator const automationProvenanceRequest = options?.automationProvenanceRequest const linkedWorkItem = options?.linkedWorkItem const linkedTaskSourceContext = options?.linkedTaskSourceContext + const startupDraft = options?.startupDraft try { for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) { const candidateName = getClientWorktreeCreateCandidate(name, attempt) @@ -4095,6 +4096,7 @@ export const createWorktreeSlice: StateCreator ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}), + ...(startupDraft ? { startupDraft } : {}), ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}), ...(startup ? { From faaf3d2588ded913482d160b639434516b2a8129 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:16:26 -0700 Subject: [PATCH 018/271] refactor(mobile): use shared diff and OSC implementations (#13433) --- .../app/h/[hostId]/session/[worktreeId].tsx | 2 +- mobile/src/session/mobile-diff-comments.ts | 37 ++----------------- mobile/src/terminal/TerminalWebView.tsx | 2 +- .../src/terminal/terminal-osc-link-ranges.ts | 18 --------- .../src/terminal/terminal-webview-contract.ts | 2 +- .../src/terminal/terminal-webview-messages.ts | 2 +- 6 files changed, 7 insertions(+), 56 deletions(-) delete mode 100644 mobile/src/terminal/terminal-osc-link-ranges.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 755d6605501..105f14e8c70 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -106,7 +106,7 @@ import type { TerminalModes, TerminalWebViewHandle } from '../../../../src/terminal/terminal-webview-contract' -import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges' +import { isTerminalOscLinkRanges } from '../../../../../src/shared/terminal-osc-link-ranges' import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift' import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit' import { diff --git a/mobile/src/session/mobile-diff-comments.ts b/mobile/src/session/mobile-diff-comments.ts index b204cba19c5..7fc7140fd42 100644 --- a/mobile/src/session/mobile-diff-comments.ts +++ b/mobile/src/session/mobile-diff-comments.ts @@ -1,4 +1,7 @@ import type { DiffComment, DiffReviewScope } from '../../../src/shared/types' +import { formatDiffComment, formatDiffComments } from '../../../src/shared/diff-comments-format' + +export { formatDiffComment, formatDiffComments } export type CreateMobileDiffCommentInput = { worktreeId: string @@ -16,44 +19,10 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } -function isMarkdownComment(comment: Pick): boolean { - return comment.source === 'markdown' -} - function normalizeScope(value: unknown): DiffReviewScope | undefined { return value === 'unstaged' || value === 'staged' || value === 'branch' ? value : undefined } -// Why: mobile Vitest/Metro run from the mobile package and cannot transform -// runtime imports from root src/shared. Keep this byte-for-byte compatible with -// the desktop shared formatter contract. -export function formatDiffComment(c: DiffComment): string { - const escaped = c.body - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\r/g, '\\r') - .replace(/\n/g, '\\n') - const locationLabel = - c.lineNumber === 0 - ? 'Scope: file' - : c.startLine !== undefined && c.startLine !== c.lineNumber - ? `Lines: ${c.startLine}-${c.lineNumber}` - : `Line: ${c.lineNumber}` - if (!isMarkdownComment(c)) { - return [`File: ${c.filePath}`, locationLabel, `User comment: "${escaped}"`].join('\n') - } - return [ - `File: ${c.filePath}`, - 'Source: markdown', - locationLabel, - `User comment: "${escaped}"` - ].join('\n') -} - -export function formatDiffComments(comments: readonly DiffComment[]): string { - return comments.map(formatDiffComment).join('\n\n') -} - export function formatMobileDiffReviewPrompt(comments: readonly DiffComment[]): string { return [ 'You are reviewing the current worktree. Address the following mobile review notes.', diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index f7ab3af1a3c..d09c0661240 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -1,7 +1,7 @@ import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react' import { Platform, View } from 'react-native' import { WebView, type WebViewMessageEvent } from 'react-native-webview' -import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' +import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract' import { TerminalWebViewEngineErrorOverlay, diff --git a/mobile/src/terminal/terminal-osc-link-ranges.ts b/mobile/src/terminal/terminal-osc-link-ranges.ts deleted file mode 100644 index 5f7f18f82d1..00000000000 --- a/mobile/src/terminal/terminal-osc-link-ranges.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' - -export type { TerminalOscLinkRange } - -export function isTerminalOscLinkRanges(value: unknown): value is TerminalOscLinkRange[] { - return ( - Array.isArray(value) && - value.every( - (entry) => - entry != null && - typeof entry === 'object' && - Number.isInteger((entry as TerminalOscLinkRange).row) && - Number.isInteger((entry as TerminalOscLinkRange).startCol) && - Number.isInteger((entry as TerminalOscLinkRange).endCol) && - typeof (entry as TerminalOscLinkRange).uri === 'string' - ) - ) -} diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index c28fd4a5038..45b9a38c05a 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -1,6 +1,6 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' +import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' import type { StyleProp, ViewStyle } from 'react-native' -import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' diff --git a/mobile/src/terminal/terminal-webview-messages.ts b/mobile/src/terminal/terminal-webview-messages.ts index c031363268c..35c6e991b68 100644 --- a/mobile/src/terminal/terminal-webview-messages.ts +++ b/mobile/src/terminal/terminal-webview-messages.ts @@ -1,5 +1,5 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' -import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' +import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' export type TerminalWebViewCommand = | { type: 'ping'; id?: number } From 6c765d5ad3cab8827d0939ad1756a94f04b87cc3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:25:28 -0700 Subject: [PATCH 019/271] refactor(mobile): remove native chat re-export facades (#13436) --- mobile/src/session/MobileNativeChatAsk.tsx | 2 +- .../session/MobileNativeChatMessage.test.ts | 2 +- .../src/session/MobileNativeChatMessage.tsx | 27 +++++++++---------- mobile/src/session/MobileNativeChatView.tsx | 2 +- .../session/mobile-native-chat-ask.test.ts | 4 +-- mobile/src/session/mobile-native-chat-ask.ts | 2 -- .../session/mobile-native-chat-blocks.test.ts | 4 +-- .../src/session/mobile-native-chat-blocks.ts | 11 -------- .../mobile-native-chat-controller-contract.ts | 6 ++++- .../session/mobile-native-chat-diff.test.ts | 4 +-- mobile/src/session/mobile-native-chat-diff.ts | 6 ----- .../session/mobile-native-chat-merge.test.ts | 2 +- .../src/session/mobile-native-chat-merge.ts | 9 ------- .../mobile-native-chat-merger-oracle.test.ts | 2 +- .../mobile-native-chat-message-text.ts | 3 +-- .../session/mobile-native-chat-noise.test.ts | 2 +- .../src/session/mobile-native-chat-noise.ts | 1 - .../session/mobile-native-chat-render-data.ts | 4 +-- .../mobile-native-chat-stream-frame.test.ts | 2 +- .../mobile-native-chat-stream-frame.ts | 3 ++- .../mobile-native-chat-tool-summary.test.ts | 4 +-- .../mobile-native-chat-tool-summary.ts | 13 --------- ...use-mobile-native-chat-answer-send.test.ts | 2 +- .../use-mobile-native-chat-answer-send.ts | 6 ++--- ...use-mobile-native-chat-ask-dismiss.test.ts | 2 +- .../use-mobile-native-chat-ask-dismiss.ts | 2 +- .../session/use-mobile-native-chat-prompts.ts | 2 +- .../session/use-mobile-native-chat-session.ts | 2 +- 28 files changed, 44 insertions(+), 87 deletions(-) delete mode 100644 mobile/src/session/mobile-native-chat-ask.ts delete mode 100644 mobile/src/session/mobile-native-chat-blocks.ts delete mode 100644 mobile/src/session/mobile-native-chat-diff.ts delete mode 100644 mobile/src/session/mobile-native-chat-merge.ts delete mode 100644 mobile/src/session/mobile-native-chat-noise.ts delete mode 100644 mobile/src/session/mobile-native-chat-tool-summary.ts diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx index 68258c22820..a5ba0ef7f72 100644 --- a/mobile/src/session/MobileNativeChatAsk.tsx +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -1,8 +1,8 @@ import { useMemo, useRef, useState } from 'react' import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native' import { Check } from 'lucide-react-native' +import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import { colors, radii, spacing, typography } from '../theme/mobile-theme' -import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' type Props = { prompt: AskPrompt diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index bb2335c7054..10b96cc3d20 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -1,8 +1,8 @@ import { createElement } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' +import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { MAX_TOOL_DETAIL_LENGTH } from './mobile-native-chat-tool-summary' vi.mock('react-native', async () => { const React = await import('react') diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index d9c9f6100ca..97e92decc4d 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -2,25 +2,22 @@ import { memo, useEffect, useRef, useState } from 'react' import { Image, Pressable, Text, View } from 'react-native' import * as Clipboard from 'expo-clipboard' import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native' -import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' -import { MobileMarkdown } from '../components/MobileMarkdown' -import { colors } from '../theme/mobile-theme' -import { - isImageRefBlock, - isTextBlock, - pairToolBlocks, - splitNativeChatBlocks, - type ToolPair -} from './mobile-native-chat-blocks' -import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff' -import { isRenderableImageUri } from './mobile-native-chat-image-preview' -import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles' -import { nativeChatMessageText } from './mobile-native-chat-message-text' +import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff' +import type { NativeChatDiffLine as DiffLine } from '../../../src/shared/native-chat-diff' +import { pairToolBlocks, splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' +import type { NativeChatToolPair as ToolPair } from '../../../src/shared/native-chat-tool-fold' import { createToolInputDisplay, summarizeToolRun, truncateToolDetail -} from './mobile-native-chat-tool-summary' +} from '../../../src/shared/native-chat-tool-summary' +import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types' +import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileMarkdown } from '../components/MobileMarkdown' +import { colors } from '../theme/mobile-theme' +import { isRenderableImageUri } from './mobile-native-chat-image-preview' +import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles' +import { nativeChatMessageText } from './mobile-native-chat-message-text' const MAX_VISIBLE_TOOL_PAIRS = 6 const MAX_TOOL_RUN_DIFF_ROWS = 240 diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 478d36267a7..697887bfe75 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -11,6 +11,7 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context' import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' +import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' @@ -26,7 +27,6 @@ import { MobileNativeChatComposer } from './MobileNativeChatComposer' import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' import { MobileNativeChatMessage } from './MobileNativeChatMessage' import { MobileNativeChatAsk } from './MobileNativeChatAsk' -import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' import { MobileNativeChatPermission } from './MobileNativeChatPermission' import type { MobileChatPermission } from './mobile-native-chat-permission' import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' diff --git a/mobile/src/session/mobile-native-chat-ask.test.ts b/mobile/src/session/mobile-native-chat-ask.test.ts index 6654a0e8689..47d56d68901 100644 --- a/mobile/src/session/mobile-native-chat-ask.test.ts +++ b/mobile/src/session/mobile-native-chat-ask.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { buildAskAnswerKeys, extractPendingAsk, formatAskAnswer, parseAskFromStatus -} from './mobile-native-chat-ask' +} from '../../../src/shared/native-chat-ask' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage { return { id, role: 'assistant', blocks, timestamp: 0, source: 'transcript' } diff --git a/mobile/src/session/mobile-native-chat-ask.ts b/mobile/src/session/mobile-native-chat-ask.ts deleted file mode 100644 index 50766b25ee4..00000000000 --- a/mobile/src/session/mobile-native-chat-ask.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Mobile and desktop share one parser/formatter so question-tool behavior cannot drift. -export * from '../../../src/shared/native-chat-ask' diff --git a/mobile/src/session/mobile-native-chat-blocks.test.ts b/mobile/src/session/mobile-native-chat-blocks.test.ts index 57351e4a20c..27b47576bfc 100644 --- a/mobile/src/session/mobile-native-chat-blocks.test.ts +++ b/mobile/src/session/mobile-native-chat-blocks.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { foldToolMessages, pairToolBlocks, splitNativeChatBlocks -} from './mobile-native-chat-blocks' +} from '../../../src/shared/native-chat-tool-fold' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' function msg( role: NativeChatMessage['role'], diff --git a/mobile/src/session/mobile-native-chat-blocks.ts b/mobile/src/session/mobile-native-chat-blocks.ts deleted file mode 100644 index 1cf293a79fd..00000000000 --- a/mobile/src/session/mobile-native-chat-blocks.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - isImageRefBlock, - isTextBlock, - NATIVE_CHAT_SOURCE_PRIORITY -} from '../../../src/shared/native-chat-types' -export { - foldToolMessages, - pairToolBlocks, - splitNativeChatBlocks, - type NativeChatToolPair as ToolPair -} from '../../../src/shared/native-chat-tool-fold' diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index a817b47b3ee..1a086783dbb 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -1,7 +1,11 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { + AskAnswerSelection, + AskPrompt, + parseAskFromStatus +} from '../../../src/shared/native-chat-ask' import type { detectAgentPermission } from './mobile-native-chat-permission' import type { parseAgentQuestion } from './mobile-native-chat-question' -import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' diff --git a/mobile/src/session/mobile-native-chat-diff.test.ts b/mobile/src/session/mobile-native-chat-diff.test.ts index 69277dd09ae..a19bcf54b67 100644 --- a/mobile/src/session/mobile-native-chat-diff.test.ts +++ b/mobile/src/session/mobile-native-chat-diff.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { diffFromText, diffFromToolCall } from './mobile-native-chat-diff' +import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff' describe('diffFromToolCall', () => { it('builds del/add lines from an Edit tool call', () => { @@ -36,8 +36,6 @@ describe('diffFromText', () => { expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull() }) - // Parity guard: the mobile module re-exports the shared parser, so the -- / ++ - // content collision must stay fixed on both platforms. it('counts a removed -- comment line despite the --- prefix collision', () => { const lines = diffFromText('@@ -1,2 +1,2 @@\n ctx\n---sql comment\n+new') expect(lines).toEqual([ diff --git a/mobile/src/session/mobile-native-chat-diff.ts b/mobile/src/session/mobile-native-chat-diff.ts deleted file mode 100644 index 4ace06d37d0..00000000000 --- a/mobile/src/session/mobile-native-chat-diff.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - diffFromText, - diffFromToolCall, - type NativeChatDiffLine as DiffLine, - type NativeChatDiffLineKind as DiffLineKind -} from '../../../src/shared/native-chat-diff' diff --git a/mobile/src/session/mobile-native-chat-merge.test.ts b/mobile/src/session/mobile-native-chat-merge.test.ts index 61312670512..c8de9d3ff1e 100644 --- a/mobile/src/session/mobile-native-chat-merge.test.ts +++ b/mobile/src/session/mobile-native-chat-merge.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { mergeNativeChatMessages } from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { mergeNativeChatMessages } from './mobile-native-chat-merge' function msg(id: string, overrides: Partial = {}): NativeChatMessage { return { diff --git a/mobile/src/session/mobile-native-chat-merge.ts b/mobile/src/session/mobile-native-chat-merge.ts deleted file mode 100644 index 6b6ffe1781e..00000000000 --- a/mobile/src/session/mobile-native-chat-merge.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Keep the established mobile import path while executing the shared desktop/mobile merger. -export { - applyAppend, - boundNativeChatWindow, - createNativeChatMerger, - mergeNativeChatMessages, - replaceList, - type NativeChatMerger -} from '../../../src/shared/native-chat-merge' diff --git a/mobile/src/session/mobile-native-chat-merger-oracle.test.ts b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts index 2a034fd33f3..d21d2bc00ba 100644 --- a/mobile/src/session/mobile-native-chat-merger-oracle.test.ts +++ b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts @@ -6,7 +6,7 @@ import { createNativeChatMerger, mergeNativeChatMessages, replaceList -} from './mobile-native-chat-merge' +} from '../../../src/shared/native-chat-merge' function msg(id: string, overrides: Partial = {}): NativeChatMessage { return { diff --git a/mobile/src/session/mobile-native-chat-message-text.ts b/mobile/src/session/mobile-native-chat-message-text.ts index 26aeea7680c..227488a56df 100644 --- a/mobile/src/session/mobile-native-chat-message-text.ts +++ b/mobile/src/session/mobile-native-chat-message-text.ts @@ -1,5 +1,4 @@ -import type { NativeChatBlock } from '../../../src/shared/native-chat-types' -import { isTextBlock } from './mobile-native-chat-blocks' +import { isTextBlock, type NativeChatBlock } from '../../../src/shared/native-chat-types' /** Concatenate a message's text blocks into a single copyable string. Tool * calls/results and image refs are skipped — Copy is for the agent's prose. */ diff --git a/mobile/src/session/mobile-native-chat-noise.test.ts b/mobile/src/session/mobile-native-chat-noise.test.ts index 6977b764e72..0369a609135 100644 --- a/mobile/src/session/mobile-native-chat-noise.test.ts +++ b/mobile/src/session/mobile-native-chat-noise.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { isNoiseMessage, stripNoiseMessages } from './mobile-native-chat-noise' function msg( role: NativeChatMessage['role'], diff --git a/mobile/src/session/mobile-native-chat-noise.ts b/mobile/src/session/mobile-native-chat-noise.ts deleted file mode 100644 index 1b15a7e414a..00000000000 --- a/mobile/src/session/mobile-native-chat-noise.ts +++ /dev/null @@ -1 +0,0 @@ -export { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise' diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index b3d4ba1c0fd..1ab2c0b03c3 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -3,10 +3,10 @@ import { formatNativeChatEmptyStateCopy, type NativeChatEmptyStateCopy } from '../../../src/shared/native-chat-empty-state' +import { stripNoiseMessages } from '../../../src/shared/native-chat-noise' +import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold' import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' -import { foldToolMessages } from './mobile-native-chat-blocks' import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' -import { stripNoiseMessages } from './mobile-native-chat-noise' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' /** The centered empty-state copy for a chat with no messages, mirroring the diff --git a/mobile/src/session/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts index b4ffe542266..1f00bc5ec15 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.test.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge' import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame' function message(id: string): NativeChatMessage { diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts index bef3dc5ef27..a3cef3d5464 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.ts @@ -1,5 +1,6 @@ +import { applyAppend, replaceList } from '../../../src/shared/native-chat-merge' +import type { NativeChatMerger } from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { applyAppend, replaceList, type NativeChatMerger } from './mobile-native-chat-merge' export type MobileNativeChatStreamFrame = { type?: string diff --git a/mobile/src/session/mobile-native-chat-tool-summary.test.ts b/mobile/src/session/mobile-native-chat-tool-summary.test.ts index 8e65ccf1958..f7a976b92b8 100644 --- a/mobile/src/session/mobile-native-chat-tool-summary.test.ts +++ b/mobile/src/session/mobile-native-chat-tool-summary.test.ts @@ -4,7 +4,7 @@ import { describeToolInput, summarizeToolInput, toolFilePath -} from './mobile-native-chat-tool-summary' +} from '../../../src/shared/native-chat-tool-summary' describe('summarizeToolInput', () => { it('passes short strings through, collapsing whitespace', () => { @@ -59,7 +59,7 @@ describe('toolFilePath', () => { }) describe('describeToolInput', () => { - it('is re-exported and labels rows with the path or primary argument', () => { + it('labels rows with the path or primary argument', () => { expect(describeToolInput({ file_path: 'src/a.ts', offset: 3 })).toBe('src/a.ts') expect(describeToolInput('{"cmd":"git status"}')).toBe('git status') }) diff --git a/mobile/src/session/mobile-native-chat-tool-summary.ts b/mobile/src/session/mobile-native-chat-tool-summary.ts deleted file mode 100644 index 0ee6576d767..00000000000 --- a/mobile/src/session/mobile-native-chat-tool-summary.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { - briefToolArg, - countToolCalls, - createToolInputDisplay, - describeToolInput, - formatToolInput, - isStructuredToolInput, - MAX_TOOL_DETAIL_LENGTH, - summarizeToolInput, - summarizeToolRun, - toolFilePath, - truncateToolDetail -} from '../../../src/shared/native-chat-tool-summary' diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts index 64fa30aecc1..82db799deed 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -1,11 +1,11 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AskPrompt } from '../../../src/shared/native-chat-ask' import type { AgentType } from '../../../src/shared/native-chat-types' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' -import type { AskPrompt } from './mobile-native-chat-ask' import { isMobileNativeChatInputStale, markMobileNativeChatInputStale, diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts index b893f7ae071..40925931d3c 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -1,6 +1,4 @@ import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' -import type { RpcClient } from '../transport/rpc-client' -import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' import { buildAskAnswerKeys, buildCodexAskAnswerKeys, @@ -8,7 +6,9 @@ import { hasAskAnswer, type AskAnswerSelection, type AskPrompt -} from './mobile-native-chat-ask' +} from '../../../src/shared/native-chat-ask' +import type { RpcClient } from '../transport/rpc-client' +import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' import { openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts index de9d53fb094..49d69ab4c3b 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts @@ -1,7 +1,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AskPrompt } from './mobile-native-chat-ask' +import type { AskPrompt } from '../../../src/shared/native-chat-ask' import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' describe('useMobileNativeChatAskDismiss', () => { diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts index 35d6cf1c7c8..beb46b45d95 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { nativeChatAskDismissKey, type AskPrompt } from './mobile-native-chat-ask' +import { nativeChatAskDismissKey, type AskPrompt } from '../../../src/shared/native-chat-ask' type AskDismissal = { sessionKey: string | null; askKey: string } type DetectedAsk = { sessionKey: string | null; askKey: string | null } diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 61b831dafd2..35acecbe844 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import { parseAskFromStatus, resolveNativeChatAsk } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { parseAskFromStatus, resolveNativeChatAsk } from './mobile-native-chat-ask' import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' import { parseAgentQuestion } from './mobile-native-chat-question' diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 6305d0418d9..656847079f9 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -3,10 +3,10 @@ import { createNativeChatTranscriptRetention, encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' +import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' import type { RpcClient } from '../transport/rpc-client' -import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge' import { applyMobileNativeChatStreamFrame, type MobileNativeChatStreamFrame From ce8c3267b7bc0103f0b22089ebf4cc9f01a2ec63 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:27:01 -0700 Subject: [PATCH 020/271] feat(terminal): add anchored link action popovers (#13414) --- .../src/components/settings/BrowserPane.tsx | 16 +- .../BrowserTerminalLinkActionsSetting.tsx | 47 +++++ .../settings/browser-link-routing-copy.ts | 8 + .../settings/browser-search.test.ts | 14 +- .../src/components/settings/browser-search.ts | 43 ++++- .../TerminalLinkActionPopover.test.tsx | 170 ++++++++++++++++++ .../TerminalLinkActionPopover.tsx | 140 +++++++++++++++ .../components/terminal-pane/TerminalPane.tsx | 22 +++ .../terminal-file-link-actions.test.ts | 97 ++++++++++ .../terminal-file-link-actions.ts | 96 ++++++++++ .../terminal-handle-links.test.ts | 40 ++++- .../terminal-pane/terminal-handle-links.ts | 63 +++++-- .../terminal-http-link-activation.ts | 6 +- .../terminal-link-action-request.ts | 67 +++++++ .../terminal-link-action-routing.test.ts | 162 +++++++++++++++++ .../terminal-link-activation.test.ts | 53 ++++++ .../terminal-pane/terminal-link-activation.ts | 27 +++ .../terminal-link-handlers.test.ts | 51 ++++-- .../terminal-pane/terminal-link-handlers.ts | 46 +++-- .../terminal-link-open-hints.test.ts | 19 +- .../terminal-pane/terminal-link-open-hints.ts | 55 ++++-- .../terminal-link-pointer-gesture.test.ts | 93 ++++++++++ .../terminal-link-pointer-gesture.ts | 68 +++++++ .../terminal-link-pty-mouse-suppression.ts | 11 +- .../terminal-linkifier-click-priming.test.ts | 4 +- .../terminal-linkifier-click-priming.ts | 4 +- .../terminal-osc-link-routing.ts | 101 +++++++---- .../terminal-url-link-click.test.ts | 56 +++++- .../terminal-url-link-hit-testing.ts | 135 ++++++++++++-- .../terminal-pane/terminal-web-link-click.ts | 24 +-- .../use-terminal-pane-lifecycle.ts | 83 +++++++-- src/renderer/src/i18n/locales/en.json | 25 +++ .../src/lib/http-link-routing.test.ts | 28 +++ src/renderer/src/lib/http-link-routing.ts | 6 +- .../pane-manager/pane-dom-creation.test.ts | 23 ++- .../src/lib/pane-manager/pane-dom-creation.ts | 2 +- .../lib/pane-manager/pane-manager-types.ts | 2 +- .../src/lib/settings-navigation-types.ts | 1 + src/shared/constants.test.ts | 4 + src/shared/constants.ts | 1 + src/shared/telemetry-events.ts | 1 + src/shared/types.ts | 2 + 42 files changed, 1750 insertions(+), 166 deletions(-) create mode 100644 src/renderer/src/components/settings/BrowserTerminalLinkActionsSetting.tsx create mode 100644 src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-action-request.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 5c8088cc9bb..1bc078cfcca 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -12,6 +12,7 @@ import { BrowserUseSetup } from './BrowserUsePane' import { BrowserSearchEngineSetting } from './BrowserSearchEngineSetting' import { BrowserLinkRoutingSetting } from './BrowserLinkRoutingSetting' import { BrowserLinkRoutingModifierSetting } from './BrowserLinkRoutingModifierSetting' +import { BrowserTerminalLinkActionsSetting } from './BrowserTerminalLinkActionsSetting' import { BrowserLocalhostWorktreeLabelsSetting } from './BrowserLocalhostWorktreeLabelsSetting' import { BrowserSessionCookiesSection } from './BrowserSessionCookiesSection' import { BrowserNewProfileDialog } from './BrowserNewProfileDialog' @@ -104,8 +105,11 @@ export function BrowserPane({ const showLinkRoutingModifier = matchesSettingsSearch(searchQuery, [ getBrowserPaneSearchEntries()[4] ]) - const showLocalhostLabels = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[5]]) - const showCookies = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[6]]) + const showTerminalLinkActions = matchesSettingsSearch(searchQuery, [ + getBrowserPaneSearchEntries()[5] + ]) + const showLocalhostLabels = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[6]]) + const showCookies = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[7]]) const showBrowserUse = matchesSettingsSearch(searchQuery, getBrowserUsePaneSearchEntries()) const isMac = isMacUserAgent() const linkRoutingDescription = getBrowserLinkRoutingDescription( @@ -254,6 +258,14 @@ export function BrowserPane({ /> ) : null} + {showTerminalLinkActions ? ( + + ) : null} + {showLocalhostLabels ? ( + isMac: boolean + updateSettings: (updates: Partial) => void +} + +export function BrowserTerminalLinkActionsSetting({ + settings, + isMac, + updateSettings +}: BrowserTerminalLinkActionsSettingProps): React.JSX.Element { + const title = translate( + 'auto.components.settings.BrowserTerminalLinkActionsSetting.title', + 'Show terminal link actions' + ) + const description = getTerminalLinkActionsDescription({ isMac }) + + return ( + +
+ + updateSettings({ + terminalLinkActionPopoverEnabled: settings.terminalLinkActionPopoverEnabled === false + }) + } + /> +
+
+ ) +} diff --git a/src/renderer/src/components/settings/browser-link-routing-copy.ts b/src/renderer/src/components/settings/browser-link-routing-copy.ts index 5a4d5bac2d6..bdf005749e2 100644 --- a/src/renderer/src/components/settings/browser-link-routing-copy.ts +++ b/src/renderer/src/components/settings/browser-link-routing-copy.ts @@ -4,6 +4,14 @@ export function getBrowserLinkRoutingShortcutLabel(platform: { isMac: boolean }) return platform.isMac ? '⇧⌘-click' : 'Shift+Ctrl+click' } +export function getTerminalLinkActionsDescription(platform: { isMac: boolean }): string { + return translate( + 'auto.components.settings.BrowserTerminalLinkActionsSetting.description', + 'Show available actions when you click a terminal link. Turn this off to require {{modifier}}-click.', + { modifier: platform.isMac ? '⌘' : 'Ctrl' } + ) +} + // Why: "always" stops being true once inverting is on, so only then does the nested // row take over the chord sentence — with it off this reads exactly as it always has. // Each variant is a complete catalog entry: stitched suffixes break in locales whose diff --git a/src/renderer/src/components/settings/browser-search.test.ts b/src/renderer/src/components/settings/browser-search.test.ts index 0df9173b28d..b9a36f148d7 100644 --- a/src/renderer/src/components/settings/browser-search.test.ts +++ b/src/renderer/src/components/settings/browser-search.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import ko from '@/i18n/locales/ko.json' import { i18n } from '@/i18n/i18n' -import { getBrowserPaneSearchEntries } from './browser-search' +import { getBrowserPaneSearchEntries, getTerminalLinkActionSearchKeywords } from './browser-search' import { getBrowserLinkRoutingDescription, getBrowserLinkRoutingShortcutLabel, @@ -47,6 +47,17 @@ describe('browser settings search copy', () => { expect(linkRoutingEntry?.description).toBe(getBrowserLinkRoutingDescription({ isMac: false })) expect(linkRoutingEntry?.keywords).toContain('ctrl') expect(linkRoutingEntry?.keywords).not.toContain('cmd') + + const terminalActionsEntry = getBrowserPaneSearchEntries({ isMac: false }).find( + (entry) => entry.title === 'Show terminal link actions' + ) + expect(terminalActionsEntry?.description).toContain('Ctrl-click') + expect(terminalActionsEntry?.description).not.toContain('Cmd/Ctrl') + expect(terminalActionsEntry?.keywords).toEqual( + getTerminalLinkActionSearchKeywords({ isMac: false }) + ) + expect(terminalActionsEntry?.keywords).toContain('browser') + expect(terminalActionsEntry?.keywords).toContain('ctrl') }) // Why: shipping the opt-in must not reword this row for anyone who never enables @@ -79,6 +90,7 @@ describe('browser link routing modifier copy', () => { 'Default Zoom', 'Link Routing', 'Hold Shift to open in Orca', + 'Show terminal link actions', 'Localhost Worktree Labels', 'Session & Cookies' ]) diff --git a/src/renderer/src/components/settings/browser-search.ts b/src/renderer/src/components/settings/browser-search.ts index 67f5506de42..ad31404e922 100644 --- a/src/renderer/src/components/settings/browser-search.ts +++ b/src/renderer/src/components/settings/browser-search.ts @@ -3,11 +3,12 @@ import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { getBrowserLinkRoutingDescription, + getTerminalLinkActionsDescription, getLinkRoutingModifierDescription, getLinkRoutingModifierTitle } from './browser-link-routing-copy' -type BrowserShortcutPlatform = { +export type BrowserShortcutPlatform = { isMac: boolean } @@ -17,6 +18,38 @@ function getDefaultBrowserShortcutPlatform(): BrowserShortcutPlatform { } } +export function getTerminalLinkActionSearchKeywords(platform: BrowserShortcutPlatform): string[] { + return [ + ...translateSearchKeyword('auto.components.settings.browser.search.2d2d995c58', 'browser'), + ...translateSearchKeyword('auto.components.settings.browser.search.bea27bac4b', 'links'), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.terminalLinkActions.terminal', + 'terminal' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.terminalLinkActions.click', + 'click' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.terminalLinkActions.actions', + 'actions' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.terminalLinkActions.popover', + 'popover' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.terminalLinkActions.menu', + 'menu' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.terminalLinkActions.disable', + 'disable' + ), + platform.isMac ? 'cmd' : 'ctrl' + ] +} + export function getBrowserPaneSearchEntries( platform: BrowserShortcutPlatform = getDefaultBrowserShortcutPlatform() ): SettingsSearchEntry[] { @@ -138,6 +171,14 @@ export function getBrowserPaneSearchEntries( platform.isMac ? 'cmd' : 'ctrl' ] }, + { + title: translate( + 'auto.components.settings.BrowserTerminalLinkActionsSetting.title', + 'Show terminal link actions' + ), + description: getTerminalLinkActionsDescription(platform), + keywords: getTerminalLinkActionSearchKeywords(platform) + }, { title: translate( 'auto.components.settings.browser.search.19ea5607cf', diff --git a/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx new file mode 100644 index 00000000000..197f5da8269 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx @@ -0,0 +1,170 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' +import type { TerminalLinkActionRequest } from './terminal-link-action-request' + +const mocks = vi.hoisted(() => ({ + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (state: { + openSettingsPage: () => void + openSettingsTarget: (target: unknown) => void + }) => unknown + ) => + selector({ + openSettingsPage: mocks.openSettingsPage, + openSettingsTarget: mocks.openSettingsTarget + }) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, + TooltipTrigger: ({ children }: { children: ReactNode }) => children, + TooltipContent: ({ children }: { children: ReactNode }) => {children} +})) + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ + children, + open, + onOpenChange + }: { + children: ReactNode + open: boolean + onOpenChange: (open: boolean) => void + }) => + open ? ( +
+
+ ) : null, + PopoverAnchor: () => null, + PopoverContent: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ) +})) + +import { TerminalLinkActionPopover } from './TerminalLinkActionPopover' + +afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + +describe('TerminalLinkActionPopover', () => { + it('shows the full destination and runs the selected action', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const onClose = vi.fn() + const focusTerminal = vi.fn() + const run = vi.fn() + const request: TerminalLinkActionRequest = { + paneId: 1, + anchorX: 100, + anchorY: 200, + destination: 'https://example.com/full/hidden/destination?query=actual', + kind: 'url', + primary: { label: 'Open link', run }, + alternate: { external: true, label: 'System Browser', run: vi.fn() }, + focusTerminal + } + + render() + + const destination = screen.getByText(request.destination) + expect(destination.className).toContain('line-clamp-2') + expect(destination.getAttribute('title')).toBe(request.destination) + expect(screen.getByText('System Browser')).toBeTruthy() + expect(screen.getAllByText('Click')).toHaveLength(2) + const textOnlyAction = screen.getByText('Open link').closest('button') + const externalAction = screen.getByText('System Browser').closest('button') + expect(textOnlyAction?.querySelector('svg')).toBeNull() + expect(externalAction?.querySelector('svg')).toBeTruthy() + expect(externalAction?.className).toContain('has-[>svg]:px-1.5') + expect(externalAction?.className).not.toContain('has-[>svg]:px-3') + expect(destination.parentElement?.className).toContain('items-center') + expect(destination.parentElement?.className).toContain('py-0.5') + expect(destination.closest('[class*="w-max"]')).toBeTruthy() + + fireEvent.click(screen.getByText('Open link')) + expect(onClose).toHaveBeenCalledOnce() + expect(focusTerminal).toHaveBeenCalledOnce() + expect(run).toHaveBeenCalledOnce() + }) + + it('identifies the dismissed request so a newer request can survive', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const onClose = vi.fn() + const request: TerminalLinkActionRequest = { + paneId: 1, + anchorX: 100, + anchorY: 200, + destination: 'https://example.com', + kind: 'url', + primary: { label: 'Open link', run: vi.fn() }, + focusTerminal: vi.fn() + } + + render() + fireEvent.click(screen.getByTestId('dismiss-popover')) + + expect(onClose).toHaveBeenCalledWith(request) + }) + + it('uses distinct icons for system and Orca browser actions', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const request: TerminalLinkActionRequest = { + paneId: 1, + anchorX: 100, + anchorY: 200, + destination: 'https://example.com', + kind: 'url', + primary: { external: false, label: 'Orca Browser', run: vi.fn() }, + alternate: { external: true, label: 'System Browser', run: vi.fn() }, + focusTerminal: vi.fn() + } + + render() + + expect( + screen.getByText('Orca Browser').closest('button')?.querySelector('.lucide-globe') + ).toBeTruthy() + expect( + screen.getByText('System Browser').closest('button')?.querySelector('.lucide-external-link') + ).toBeTruthy() + }) + + it('opens the terminal link setting from the compact settings button', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const onClose = vi.fn() + const focusTerminal = vi.fn() + const request: TerminalLinkActionRequest = { + paneId: 1, + anchorX: 100, + anchorY: 200, + destination: 'https://example.com', + kind: 'url', + primary: { label: 'System Browser', run: vi.fn() }, + focusTerminal + } + + render() + fireEvent.click(screen.getByRole('button', { name: 'Terminal link settings' })) + + expect(onClose).toHaveBeenCalledOnce() + expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'browser', + repoId: null, + sectionId: BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID + }) + expect(mocks.openSettingsPage).toHaveBeenCalledOnce() + expect(focusTerminal).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx new file mode 100644 index 00000000000..c313edb545a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx @@ -0,0 +1,140 @@ +import { useMemo } from 'react' +import { ExternalLink, Globe, Settings } from 'lucide-react' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { Button } from '@/components/ui/button' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' +import { useAppStore } from '@/store' +import type { TerminalLinkAction, TerminalLinkActionRequest } from './terminal-link-action-request' + +type TerminalLinkActionPopoverProps = { + request: TerminalLinkActionRequest | null + onClose: (dismissed?: TerminalLinkActionRequest) => void +} + +function ActionRow({ + action, + alternate, + onRun +}: { + action: TerminalLinkAction + alternate: boolean + onRun: () => void +}): React.JSX.Element { + const isMac = navigator.userAgent.includes('Mac') + const keys = alternate + ? [isMac ? '⇧' : 'Shift', isMac ? '⌘' : 'Ctrl', 'Click'] + : [isMac ? '⌘' : 'Ctrl', 'Click'] + + return ( + + ) +} + +export function TerminalLinkActionPopover({ + request, + onClose +}: TerminalLinkActionPopoverProps): React.JSX.Element { + const openSettingsPage = useAppStore((state) => state.openSettingsPage) + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + const virtualRef = useMemo( + () => ({ + current: { + getBoundingClientRect: () => new DOMRect(request?.anchorX ?? 0, request?.anchorY ?? 0, 0, 0) + } + }), + [request?.anchorX, request?.anchorY] + ) + + const runAction = (action: TerminalLinkAction): void => { + onClose() + request?.focusTerminal() + void action.run() + } + + const settingsLabel = translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.terminalLinkSettings', + 'Terminal link settings' + ) + + const openTerminalLinkSettings = (): void => { + onClose() + openSettingsTarget({ + pane: 'browser', + repoId: null, + sectionId: BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID + }) + openSettingsPage() + } + + return ( + !open && onClose(request ?? undefined)} + > + + {request ? ( + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + onEscapeKeyDown={() => request.focusTerminal()} + > +
+ + {request.destination} + + + + + + + {settingsLabel} + + +
+ runAction(request.primary)} + /> + {request.alternate ? ( + runAction(request.alternate!)} + /> + ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index e4862c5f085..d0e9dee537d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -93,6 +93,11 @@ import { import { useSystemPrefersDark } from './use-system-prefers-dark' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle' +import { TerminalLinkActionPopover } from './TerminalLinkActionPopover' +import { + closeTerminalLinkActionRequest, + type TerminalLinkActionRequest +} from './terminal-link-action-request' import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu' import { detachTerminalPaneToTab, @@ -390,6 +395,14 @@ function TerminalPane( const [paneCount, setPaneCount] = useState(0) // Why: pane reorders can move panes without changing count or size, so overlay rects need an explicit layout-change render trigger. const [paneLayoutRevision, setPaneLayoutRevision] = useState(0) + const [terminalLinkActionRequest, setTerminalLinkActionRequest] = + useState(null) + const requestTerminalLinkAction = useCallback((request: TerminalLinkActionRequest) => { + setTerminalLinkActionRequest(request) + }, []) + const closeTerminalLinkActions = useCallback((dismissed?: TerminalLinkActionRequest) => { + setTerminalLinkActionRequest((current) => closeTerminalLinkActionRequest(current, dismissed)) + }, []) const [searchOpen, setSearchOpen] = useState(false) const searchOpenRef = useRef(false) searchOpenRef.current = searchOpen @@ -1382,6 +1395,7 @@ function TerminalPane( settings, settingsRef, requestOpenLinksInAppPreference, + requestTerminalLinkAction, effectiveMacOptionAsAlt, effectiveMacOptionAsAltRef: macOptionAsAltRef, initialLayoutRef, @@ -1434,6 +1448,10 @@ function TerminalPane( onExternalPaneDrop: handleExternalPaneDrop }) + useEffect(() => { + closeTerminalLinkActions() + }, [closeTerminalLinkActions, isActive, isRendererVisible, paneLayoutRevision]) + useEffect(() => { const manager = managerRef.current if (!manager || !restoredLayout.root) { @@ -3112,6 +3130,10 @@ function TerminalPane( onCopyTerminalId={() => void contextMenu.onCopyTerminalId()} onCopyPaneId={contextMenu.onCopyPaneId} /> + {/* Why: repos is a broad store slice; only subscribe while the editor is visible. */} {quickCommandEditorOpen ? ( ({ + canOpenWithSystemDefault: true, + openDetectedFilePath: vi.fn(), + worktreeRoot: false +})) + +vi.mock('./terminal-file-open-routing', () => ({ + getTerminalFileContext: () => ({}), + mapTerminalFilePath: (filePath: string) => filePath, + openDetectedFilePath: mocks.openDetectedFilePath, + shouldOpenTerminalFileWithSystemDefault: () => mocks.canOpenWithSystemDefault, + terminalLinkWslDistro: () => null +})) + +vi.mock('./terminal-worktree-path-link', () => ({ + resolveKnownWorktreeRootPathLink: () => (mocks.worktreeRoot ? { id: 'wt-2' } : null) +})) + +import { handleTerminalFileLink } from './terminal-file-link-actions' + +const deps = { worktreeId: 'wt-1', worktreePath: '/repo' } + +function plainEvent(): MouseEvent { + return { + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + clientX: 12, + clientY: 24, + preventDefault: vi.fn() + } as unknown as MouseEvent +} + +function context(request: ReturnType): TerminalLinkActionContext { + return { + paneId: 3, + pointerGesture: { canRequestAction: () => true, dispose: vi.fn() }, + request: request as TerminalLinkActionContext['request'], + focusTerminal: vi.fn() + } +} + +beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + mocks.canOpenWithSystemDefault = true + mocks.worktreeRoot = false + vi.clearAllMocks() +}) + +afterEach(() => vi.unstubAllGlobals()) + +describe('terminal file link actions', () => { + it('offers Orca and system-default actions for a local file', () => { + const request = vi.fn() + expect( + handleTerminalFileLink('/repo/src/main.ts', 12, 4, plainEvent(), deps, context(request)) + ).toBe(true) + + const actionRequest = request.mock.calls[0][0] + expect(actionRequest).toEqual( + expect.objectContaining({ + destination: '/repo/src/main.ts', + kind: 'file', + primary: expect.objectContaining({ label: 'Open file' }), + alternate: expect.objectContaining({ label: 'Open with default app' }) + }) + ) + actionRequest.primary.run() + actionRequest.alternate.run() + expect(mocks.openDetectedFilePath).toHaveBeenNthCalledWith(1, '/repo/src/main.ts', 12, 4, deps) + expect(mocks.openDetectedFilePath).toHaveBeenNthCalledWith(2, '/repo/src/main.ts', 12, 4, { + ...deps, + openWithSystemDefault: true + }) + }) + + it('labels workspace switching and omits an impossible remote alternate', () => { + mocks.worktreeRoot = true + mocks.canOpenWithSystemDefault = false + const request = vi.fn() + handleTerminalFileLink('/repo', null, null, plainEvent(), deps, context(request)) + + const actionRequest = request.mock.calls[0][0] + expect(actionRequest).toEqual( + expect.objectContaining({ + kind: 'workspace', + primary: expect.objectContaining({ label: 'Switch workspace' }) + }) + ) + expect(actionRequest).not.toHaveProperty('alternate') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts new file mode 100644 index 00000000000..bd3247ee9d2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts @@ -0,0 +1,96 @@ +import { + getTerminalFileContext, + mapTerminalFilePath, + openDetectedFilePath, + shouldOpenTerminalFileWithSystemDefault, + terminalLinkWslDistro +} from './terminal-file-open-routing' +import { isTerminalLinkDirectActivation } from './terminal-link-activation' +import { + requestTerminalLinkAction, + type TerminalLinkActionContext +} from './terminal-link-action-request' +import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link' +import { translate } from '@/i18n/i18n' + +export type TerminalFileLinkActionDeps = { + worktreeId: string + worktreePath: string + runtimeEnvironmentId?: string | null + wslDistro?: string | null +} + +export function handleTerminalFileLink( + filePath: string, + line: number | null, + column: number | null, + event: MouseEvent | undefined, + deps: TerminalFileLinkActionDeps, + actionContext?: TerminalLinkActionContext | null, + actionDestination?: string +): boolean { + if (isTerminalLinkDirectActivation(event)) { + event?.preventDefault?.() + openDetectedFilePath(filePath, line, column, { + ...deps, + openWithSystemDefault: Boolean(event?.shiftKey) + }) + return true + } + + const mappedPath = mapTerminalFilePath( + filePath, + deps.worktreePath, + terminalLinkWslDistro(deps.wslDistro, deps.runtimeEnvironmentId) + ) + const fileContext = getTerminalFileContext( + deps.worktreeId, + deps.worktreePath, + deps.runtimeEnvironmentId + ) + const worktreeRoot = resolveKnownWorktreeRootPathLink(mappedPath) + const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault(fileContext, mappedPath) + const isMac = navigator.userAgent.includes('Mac') + + return requestTerminalLinkAction(event, actionContext, { + destination: actionDestination ?? mappedPath, + kind: worktreeRoot ? 'workspace' : 'file', + primary: { + label: worktreeRoot + ? translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.switchWorkspace', + 'Switch workspace' + ) + : translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.openFile', + 'Open file' + ), + run: () => openDetectedFilePath(filePath, line, column, deps) + }, + ...(canOpenWithSystemDefault + ? { + alternate: { + label: worktreeRoot + ? isMac + ? translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.openInFinder', + 'Open in Finder' + ) + : translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.openFolder', + 'Open folder' + ) + : translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.openWithDefaultApp', + 'Open with default app' + ), + run: () => + openDetectedFilePath(filePath, line, column, { + ...deps, + openWithSystemDefault: true + }) + } + } + : {}) + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts index 2fb6064f814..3c042a12d24 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts @@ -11,6 +11,7 @@ import { findTerminalHandleTarget, focusRendererTerminalHandle } from './terminal-handle-links' +import type { TerminalLinkActionContext } from './terminal-link-action-request' const mocks = vi.hoisted(() => ({ activateTabAndFocusPane: vi.fn(), @@ -84,7 +85,8 @@ function setPlatform(userAgent: string): void { async function collectLinks( rows: TestBufferLine[], bufferLineNumber = 1, - runtimeEnvironmentId: string | null = null + runtimeEnvironmentId: string | null = null, + linkActionContext?: TerminalLinkActionContext ): Promise { const terminal = { buffer: { @@ -97,7 +99,8 @@ async function collectLinks( const provider = createTerminalHandleLinkProvider({ getTerminal: () => terminal as never, getRuntimeEnvironmentId: () => runtimeEnvironmentId, - linkTooltip: { textContent: '', style: { display: '' } } as unknown as HTMLElement + linkTooltip: { textContent: '', style: { display: '' } } as unknown as HTMLElement, + getLinkActionContext: () => linkActionContext ?? null }) return await new Promise((resolve) => { provider.provideLinks(bufferLineNumber, (links) => resolve(links ?? [])) @@ -303,6 +306,39 @@ describe('createTerminalHandleLinkProvider', () => { }) }) + it('defers a plain terminal-handle click to the action popover', async () => { + const request = vi.fn() + const links = await collectLinks([makeBufferLine('Worker: term_worker')], 1, null, { + paneId: 4, + pointerGesture: { canRequestAction: () => true, dispose: vi.fn() }, + request, + focusTerminal: vi.fn() + }) + + links[0].activate( + { + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + clientX: 20, + clientY: 30, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + destination: 'term_worker', + kind: 'terminal', + primary: expect.objectContaining({ label: 'Switch terminal' }) + }) + ) + expect(window.api.runtime.call).not.toHaveBeenCalled() + }) + it('uses the owning runtime for terminal links when a renderer match belongs to another runtime', async () => { markRuntimeEnvironmentCompatible('env-1') mocks.storeState.tabsByWorktree = { diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts index baf2adbde90..b9b949fc83f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts @@ -11,6 +11,15 @@ import { focusRuntimeOrchestrationTask, ORCHESTRATION_TASK_PREFIX } from './terminal-orchestration-task-links' +import { + isTerminalLinkActionActivation, + isTerminalLinkDirectActivation +} from './terminal-link-activation' +import { + requestTerminalLinkAction, + type TerminalLinkActionContext +} from './terminal-link-action-request' +import { translate } from '@/i18n/i18n' export { extractOrchestrationTaskLinks } from './terminal-orchestration-task-links' export type { ParsedOrchestrationTaskLink } from './terminal-orchestration-task-links' @@ -36,6 +45,7 @@ type TerminalHandleLinkProviderDeps = { getTerminal: () => Terminal | null getRuntimeEnvironmentId: () => string | null linkTooltip: HTMLElement + getLinkActionContext?: () => TerminalLinkActionContext | null } const TERMINAL_HANDLE_PREFIX = 'term_' @@ -188,15 +198,44 @@ export function createTerminalHandleLinkProvider( range, text: parsed.text, activate: (event) => { - if (!isTerminalHandleLinkActivation(event)) { + const directActivation = isTerminalLinkDirectActivation(event) + const actionActivation = isTerminalLinkActionActivation(event) + if (!directActivation && !actionActivation) { return } - event?.preventDefault() - void activateParsedLink(parsed, deps.getRuntimeEnvironmentId()) - terminal.clearSelection() + let handled = false + if (directActivation) { + event?.preventDefault() + void activateParsedLink(parsed, deps.getRuntimeEnvironmentId()) + handled = true + } else { + handled = requestTerminalLinkAction(event, deps.getLinkActionContext?.(), { + destination: parsed.text, + kind: parsed.kind, + primary: { + label: + parsed.kind === 'terminal' + ? translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.switchTerminal', + 'Switch terminal' + ) + : translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.openTaskTerminal', + 'Open task terminal' + ), + run: () => activateParsedLink(parsed, deps.getRuntimeEnvironmentId()) + } + }) + } + if (handled) { + terminal.clearSelection() + } }, hover: () => { - deps.linkTooltip.textContent = `${parsed.text} (${getTerminalHandleFocusHint()})` + const showActions = deps.getLinkActionContext + ? deps.getLinkActionContext() !== null + : true + deps.linkTooltip.textContent = `${parsed.text} (${getTerminalHandleFocusHint(showActions)})` deps.linkTooltip.style.display = '' }, leave: () => { @@ -252,17 +291,11 @@ function ptyIdMatchesTerminalHandle( return ptyEnvironmentId === targetEnvironmentId } -function getTerminalHandleFocusHint(): string { +function getTerminalHandleFocusHint(showActions: boolean): string { + const prefix = showActions ? 'Click for actions or ' : '' return navigator.userAgent.includes('Mac') - ? '⌘+click to switch terminal' - : 'Ctrl+click to switch terminal' -} - -function isTerminalHandleLinkActivation( - event: Pick | undefined -): boolean { - const isMac = navigator.userAgent.includes('Mac') - return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) + ? `${prefix}⌘+click to switch terminal` + : `${prefix}Ctrl+click to switch terminal` } async function focusRuntimeTerminalHandle( diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts index c9e941c874d..893ec638cbf 100644 --- a/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts @@ -1,7 +1,5 @@ -import { isTerminalLinkActivation } from './terminal-link-activation' +import { isTerminalLinkDirectActivation } from './terminal-link-activation' export function isTerminalHttpLinkActivation(event: MouseEvent | undefined): boolean { - // Why: xterm deliberately forwards Alt-modified mouse gestures to the PTY, - // so plain HTTP link handling must leave those gestures to the child TUI. - return Boolean(event && !event.altKey && isTerminalLinkActivation(event)) + return isTerminalLinkDirectActivation(event) } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts new file mode 100644 index 00000000000..653ab34597a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts @@ -0,0 +1,67 @@ +import type { TerminalLinkPointerGesture } from './terminal-link-pointer-gesture' +import { isTerminalLinkActionActivation } from './terminal-link-activation' + +export type TerminalLinkActionKind = 'url' | 'file' | 'workspace' | 'terminal' | 'task' + +export type TerminalLinkAction = { + external?: boolean + label: string + run: () => void | Promise +} + +export type TerminalLinkActionRequest = { + paneId: number + anchorX: number + anchorY: number + destination: string + kind: TerminalLinkActionKind + primary: TerminalLinkAction + alternate?: TerminalLinkAction + focusTerminal: () => void +} + +export type TerminalLinkActionRequester = (request: TerminalLinkActionRequest) => void + +export type TerminalLinkActionContext = { + paneId: number + pointerGesture: TerminalLinkPointerGesture + request: TerminalLinkActionRequester + focusTerminal: () => void +} + +export function closeTerminalLinkActionRequest( + current: TerminalLinkActionRequest | null, + dismissed?: TerminalLinkActionRequest +): TerminalLinkActionRequest | null { + return dismissed && current !== dismissed ? current : null +} + +type LinkActionDetails = Pick< + TerminalLinkActionRequest, + 'destination' | 'kind' | 'primary' | 'alternate' +> + +export function requestTerminalLinkAction( + event: MouseEvent | undefined, + context: TerminalLinkActionContext | null | undefined, + details: LinkActionDetails +): boolean { + if ( + !event || + !context || + !isTerminalLinkActionActivation(event) || + !context.pointerGesture.canRequestAction(event) + ) { + return false + } + + event.preventDefault() + context.request({ + ...details, + paneId: context.paneId, + anchorX: event.clientX, + anchorY: event.clientY, + focusTerminal: context.focusTerminal + }) + return true +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts new file mode 100644 index 00000000000..00cdcd3c687 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' +import { + closeTerminalLinkActionRequest, + type TerminalLinkActionContext, + type TerminalLinkActionRequest +} from './terminal-link-action-request' +import { handleOscLink } from './terminal-osc-link-routing' +import { handleTerminalHttpLink } from './terminal-url-link-hit-testing' + +const openUrl = vi.fn() +const createBrowserTab = vi.fn() +const setActiveWorktree = vi.fn() + +function plainEvent(): MouseEvent { + return { + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + clientX: 40, + clientY: 60, + preventDefault: vi.fn() + } as unknown as MouseEvent +} + +function actionContext(request = vi.fn()): TerminalLinkActionContext { + return { + paneId: 7, + pointerGesture: { canRequestAction: () => true, dispose: vi.fn() }, + request, + focusTerminal: vi.fn() + } +} + +beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + vi.stubGlobal('window', { api: { shell: { openUrl } } }) + registerHttpLinkStoreAccessor(() => ({ + settings: { openLinksInApp: false }, + setActiveWorktree, + createBrowserTab + })) +}) + +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + +describe('terminal link action routing', () => { + it('keeps a replacement request when the previous popover dismisses', () => { + const first = { destination: 'https://first.example' } as TerminalLinkActionRequest + const second = { destination: 'https://second.example' } as TerminalLinkActionRequest + + expect(closeTerminalLinkActionRequest(second, first)).toBe(second) + expect(closeTerminalLinkActionRequest(first, first)).toBeNull() + }) + + it('offers system browser first and Orca second when system browser is the default', () => { + const request = vi.fn() + const event = plainEvent() + + expect( + handleTerminalHttpLink('https://example.com/path', event, { + worktreeId: 'wt-1', + linkActionContext: actionContext(request), + actionDestinations: { primary: 'system', alternate: 'orca' } + }) + ).toBe(true) + expect(openUrl).not.toHaveBeenCalled() + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + paneId: 7, + anchorX: 40, + anchorY: 60, + destination: 'https://example.com/path', + kind: 'url' + }) + ) + expect(request.mock.calls[0][0].primary.label).toBe('System Browser') + expect(request.mock.calls[0][0].primary.external).toBe(true) + expect(request.mock.calls[0][0].alternate.label).toBe('Orca Browser') + expect(request.mock.calls[0][0].alternate.external).toBe(false) + + request.mock.calls[0][0].primary.run() + expect(openUrl).toHaveBeenCalledWith('https://example.com/path') + + request.mock.calls[0][0].alternate.run() + expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', { + activate: true + }) + }) + + it('offers Orca first and system browser second when Orca is the default', () => { + const request = vi.fn() + + handleTerminalHttpLink('https://example.com/path', plainEvent(), { + worktreeId: 'wt-1', + linkActionContext: actionContext(request), + actionDestinations: { primary: 'orca', alternate: 'system' } + }) + + expect(request.mock.calls[0][0].primary.label).toBe('Orca Browser') + expect(request.mock.calls[0][0].primary.external).toBe(false) + expect(request.mock.calls[0][0].alternate.label).toBe('System Browser') + expect(request.mock.calls[0][0].alternate.external).toBe(true) + + request.mock.calls[0][0].primary.run() + expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', { + activate: true + }) + + request.mock.calls[0][0].alternate.run() + expect(openUrl).toHaveBeenCalledWith('https://example.com/path') + }) + + it('offers only the system browser for a remote link', () => { + const request = vi.fn() + + handleTerminalHttpLink('https://example.com/path', plainEvent(), { + worktreeId: 'wt-1', + sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }, + linkActionContext: actionContext(request), + actionDestinations: { primary: 'system' } + }) + + expect(request.mock.calls[0][0].primary.label).toBe('System Browser') + expect(request.mock.calls[0][0].primary.external).toBe(true) + expect(request.mock.calls[0][0].alternate).toBeUndefined() + }) + + it('uses Shift+modifier for the alternate local destination', () => { + const event = { ...plainEvent(), metaKey: true, shiftKey: true } + + handleTerminalHttpLink('https://example.com/path', event, { + worktreeId: 'wt-1', + actionDestinations: { primary: 'system', alternate: 'orca' } + }) + + expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', { + activate: true + }) + expect(openUrl).not.toHaveBeenCalled() + }) + + it('exposes the actual hidden OSC 8 destination', () => { + const request = vi.fn() + const destination = 'https://example.com' + + expect( + handleOscLink(destination, plainEvent(), { + worktreeId: 'wt-1', + worktreePath: '/repo', + startupCwd: '/repo', + linkActionContext: actionContext(request) + }) + ).toBe(true) + expect(request.mock.calls[0][0].destination).toBe(destination) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts new file mode 100644 index 00000000000..ce88c8feeaf --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + isTerminalLinkActionActivation, + isTerminalLinkDirectActivation, + isTerminalOwnedLinkGesture +} from './terminal-link-activation' + +function event(overrides: Partial = {}): MouseEvent { + return { + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + ...overrides + } as MouseEvent +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('terminal link activation', () => { + it('uses plain click for actions and Command-click for direct open on macOS', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + + expect(isTerminalLinkActionActivation(event())).toBe(true) + expect(isTerminalLinkDirectActivation(event({ metaKey: true }))).toBe(true) + expect(isTerminalOwnedLinkGesture(event())).toBe(true) + }) + + it('reserves macOS Control-click for the context menu', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + + expect(isTerminalLinkActionActivation(event({ ctrlKey: true }))).toBe(false) + expect(isTerminalLinkDirectActivation(event({ ctrlKey: true }))).toBe(false) + }) + + it('uses Ctrl-click for direct open on Windows and Linux', () => { + vi.stubGlobal('navigator', { userAgent: 'Windows' }) + + expect(isTerminalLinkActionActivation(event())).toBe(true) + expect(isTerminalLinkDirectActivation(event({ ctrlKey: true }))).toBe(true) + expect(isTerminalLinkDirectActivation(event({ metaKey: true }))).toBe(false) + }) + + it('leaves Shift-only, Alt, and non-primary gestures unowned', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + + expect(isTerminalOwnedLinkGesture(event({ shiftKey: true }))).toBe(false) + expect(isTerminalOwnedLinkGesture(event({ altKey: true }))).toBe(false) + expect(isTerminalOwnedLinkGesture(event({ button: 2 }))).toBe(false) + expect(isTerminalLinkDirectActivation(event({ metaKey: true, shiftKey: true }))).toBe(true) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts index 41def2095c1..aae05daa875 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts @@ -5,3 +5,30 @@ export function isTerminalLinkActivation( ): boolean { return isMacPlatform() ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) } + +type TerminalLinkMouseEvent = Pick & + Partial> + +export function isTerminalLinkDirectActivation(event: TerminalLinkMouseEvent | undefined): boolean { + return Boolean( + event && + (event.button === undefined || event.button === 0) && + !event.altKey && + isTerminalLinkActivation(event) + ) +} + +export function isTerminalLinkActionActivation(event: TerminalLinkMouseEvent | undefined): boolean { + return Boolean( + event && + (event.button === undefined || event.button === 0) && + !event.altKey && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey + ) +} + +export function isTerminalOwnedLinkGesture(event: TerminalLinkMouseEvent | undefined): boolean { + return isTerminalLinkDirectActivation(event) || isTerminalLinkActionActivation(event) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 3bb650b0e3a..74574dea02a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -505,17 +505,36 @@ describe('handleOscLink', () => { it('advertises the system default open behavior in hover hints', () => { setPlatform('Macintosh') - expect(getTerminalFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default app') - expect(getTerminalHtmlFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default browser') - expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser') - - setPlatform('Windows') - expect(getTerminalFileOpenHint()).toBe('Ctrl+click to open or Shift+Ctrl+click for default app') + expect(getTerminalFileOpenHint()).toBe( + 'Click for actions, ⌘+click to open, or ⇧⌘+click for default app' + ) expect(getTerminalHtmlFileOpenHint()).toBe( - 'Ctrl+click to open or Shift+Ctrl+click for default browser' + 'Click for actions, ⌘+click to open, or ⇧⌘+click for default browser' ) expect(getTerminalUrlOpenHint()).toBe( - 'Ctrl+click to open or Shift+Ctrl+click for system browser' + 'Click for actions, ⌘+click to open, or ⇧⌘+click for system browser' + ) + + setPlatform('Windows') + expect(getTerminalFileOpenHint()).toBe( + 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for default app' + ) + expect(getTerminalHtmlFileOpenHint()).toBe( + 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for default browser' + ) + expect(getTerminalUrlOpenHint()).toBe( + 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for system browser' + ) + }) + + it('omits plain-click actions from hover hints when the popover is disabled', () => { + setPlatform('Macintosh') + expect(getTerminalFileOpenHint(false)).toBe('⌘+click to open, or ⇧⌘+click for default app') + expect(getTerminalHtmlFileOpenHint(false)).toBe( + '⌘+click to open, or ⇧⌘+click for default browser' + ) + expect(getTerminalUrlOpenHint({ showActions: false })).toBe( + '⌘+click to open, or ⇧⌘+click for system browser' ) }) @@ -1383,7 +1402,7 @@ describe('createFilePathLinkProvider range bounds', () => { links[0]!.hover?.({} as MouseEvent, links[0]!.text) expect(linkTooltip.textContent).toBe( - '/repo/CLAUDE.md (⌘+click to open or ⇧⌘+click for default app)' + '/repo/CLAUDE.md (Click for actions, ⌘+click to open, or ⇧⌘+click for default app)' ) }) @@ -1426,7 +1445,7 @@ describe('createFilePathLinkProvider range bounds', () => { links[0]!.hover?.({} as MouseEvent, links[0]!.text) expect(linkTooltip.textContent).toBe( - '/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)' + '/repo (Click for actions, ⌘+click to switch workspace, or ⇧⌘+click to open in Finder)' ) }) @@ -1448,7 +1467,7 @@ describe('createFilePathLinkProvider range bounds', () => { expect(window.api.shell.pathExists).not.toHaveBeenCalled() expect(linkTooltip.textContent).toBe( - '/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)' + '/repo (Click for actions, ⌘+click to switch workspace, or ⇧⌘+click to open in Finder)' ) }) @@ -1481,7 +1500,7 @@ describe('createFilePathLinkProvider range bounds', () => { links[0]!.hover?.({} as MouseEvent, links[0]!.text) expect(linkTooltip.textContent).toBe( - '/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)' + '/repo (Click for actions, ⌘+click to switch workspace, or ⇧⌘+click to open in Finder)' ) }) @@ -1499,7 +1518,9 @@ describe('createFilePathLinkProvider range bounds', () => { expect(links[0]).toBeDefined() links[0]!.hover?.({} as MouseEvent, links[0]!.text) - expect(linkTooltip.textContent).toBe('/repo (Ctrl+click to switch workspace)') + expect(linkTooltip.textContent).toBe( + '/repo (Click for actions or Ctrl+click to switch workspace)' + ) }) it('shows the Orca hint for SSH file link hover', async () => { @@ -1513,7 +1534,9 @@ describe('createFilePathLinkProvider range bounds', () => { expect(links[0]).toBeDefined() links[0]!.hover?.({} as MouseEvent, links[0]!.text) - expect(linkTooltip.textContent).toBe('/repo/CLAUDE.md (⌘+click to open in Orca)') + expect(linkTooltip.textContent).toBe( + '/repo/CLAUDE.md (Click for actions or ⌘+click to open in Orca)' + ) }) it('bounds the terminal path-exists cache while preserving recent probes', async () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 2f4b9155a4e..b3890bb18b5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -15,7 +15,6 @@ import { getTerminalFileContext, isHtmlFilePath, mapTerminalFilePath, - openDetectedFilePath, shouldOpenTerminalFileWithSystemDefault, terminalLinkWslDistro } from './terminal-file-open-routing' @@ -38,8 +37,10 @@ import { getTerminalUrlOpenHint } from './terminal-link-open-hints' import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link' -import { isTerminalLinkActivation } from './terminal-link-activation' +import { isTerminalLinkDirectActivation } from './terminal-link-activation' import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-position' +import type { TerminalLinkActionContext } from './terminal-link-action-request' +import { handleTerminalFileLink } from './terminal-file-link-actions' export { openDetectedFilePath } from './terminal-file-open-routing' export { mapTerminalFilePath } from './terminal-file-open-routing' @@ -59,6 +60,7 @@ export type LinkHandlerDeps = { terminalHomePath?: string | null wslDistro?: string | null getRuntimeEnvironmentIdForPane?: (paneId: number) => string | null + getLinkActionContext?: (paneId: number) => TerminalLinkActionContext | null } type ProvidedFileLink = { @@ -186,16 +188,23 @@ export function createFilePathLinkProvider( range, text: parsed.displayText, activate: (event) => { - if (!isTerminalLinkActivation(event)) { - return + if ( + handleTerminalFileLink( + mappedPath, + resolved.line, + resolved.column, + event, + { + worktreeId, + worktreePath, + runtimeEnvironmentId, + wslDistro: deps.wslDistro + }, + deps.getLinkActionContext?.(paneId) + ) + ) { + pane.terminal.clearSelection?.() } - openDetectedFilePath(mappedPath, resolved.line, resolved.column, { - worktreeId, - worktreePath, - runtimeEnvironmentId, - wslDistro: deps.wslDistro, - openWithSystemDefault: Boolean(event.shiftKey) - }) }, hover: () => { // Why: only local paths can offer the Shift+modifier system @@ -204,13 +213,18 @@ export function createFilePathLinkProvider( fileContext, mappedPath ) + const showActions = deps.getLinkActionContext + ? deps.getLinkActionContext(paneId) !== null + : true const hint = worktreeRootLink - ? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault) + ? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault, showActions) : canOpenWithSystemDefault ? isHtmlFilePath(mappedPath) - ? getTerminalHtmlFileOpenHint() - : openLinkHint - : getTerminalOrcaFileOpenHint() + ? getTerminalHtmlFileOpenHint(showActions) + : showActions + ? openLinkHint + : getTerminalFileOpenHint(false) + : getTerminalOrcaFileOpenHint(showActions) linkTooltip.textContent = `${mappedPath} (${hint})` linkTooltip.style.display = '' }, @@ -262,7 +276,7 @@ export function installFilePathLinkClickFallback( ): IDisposable { const mouseUpListenerOptions = { capture: true } const handleMouseUp = (event: MouseEvent): void => { - if (event.button !== 0 || !isTerminalLinkActivation(event)) { + if (!isTerminalLinkDirectActivation(event)) { return } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts index 890a8f3f4c3..801eec81c98 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts @@ -12,7 +12,9 @@ afterEach(() => { describe('getTerminalUrlOpenHint', () => { it('keeps the system-browser wording by default', () => { stubPlatform(true) - expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser') + expect(getTerminalUrlOpenHint()).toBe( + 'Click for actions, ⌘+click to open, or ⇧⌘+click for system browser' + ) }) it('keeps the system-browser wording when inverting is off', () => { @@ -34,16 +36,27 @@ describe('getTerminalUrlOpenHint', () => { it('names Orca when inverting and links open externally', () => { stubPlatform(true) expect(getTerminalUrlOpenHint({ openLinksInApp: false, modifierInverts: true })).toBe( - '⌘+click to open or ⇧⌘+click to open in Orca' + 'Click for actions, ⌘+click to open, or ⇧⌘+click to open in Orca' ) }) it('uses the Ctrl chord off macOS', () => { stubPlatform(false) expect(getTerminalUrlOpenHint({ openLinksInApp: false, modifierInverts: true })).toBe( - 'Ctrl+click to open or Shift+Ctrl+click to open in Orca' + 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click to open in Orca' ) }) + + it('omits the action-menu gesture when terminal link actions are disabled', () => { + stubPlatform(false) + expect( + getTerminalUrlOpenHint({ + openLinksInApp: false, + modifierInverts: true, + showActions: false + }) + ).toBe('Ctrl+click to open, or Shift+Ctrl+click to open in Orca') + }) }) describe('terminalUrlOpenHintOptionsFor', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts index a069ad03f44..e1a5f4ff81f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts @@ -4,27 +4,36 @@ export function isMacPlatform(): boolean { return navigator.userAgent.includes('Mac') } -export function getTerminalFileOpenHint(): string { - return isMacPlatform() - ? '⌘+click to open or ⇧⌘+click for default app' - : 'Ctrl+click to open or Shift+Ctrl+click for default app' +function terminalLinkActionHintPrefix(showActions: boolean): string { + return showActions ? 'Click for actions, ' : '' } -export function getTerminalOrcaFileOpenHint(): string { - return isMacPlatform() ? '⌘+click to open in Orca' : 'Ctrl+click to open in Orca' +export function getTerminalFileOpenHint(showActions = true): string { + const prefix = terminalLinkActionHintPrefix(showActions) + return isMacPlatform() + ? `${prefix}⌘+click to open, or ⇧⌘+click for default app` + : `${prefix}Ctrl+click to open, or Shift+Ctrl+click for default app` } -// Why: detected local .html/.htm file paths keep the same modifier gate as -// other file-path links, with Shift+modifier as the system-browser escape hatch. -export function getTerminalHtmlFileOpenHint(): string { +export function getTerminalOrcaFileOpenHint(showActions = true): string { + const prefix = showActions ? 'Click for actions or ' : '' return isMacPlatform() - ? '⌘+click to open or ⇧⌘+click for default browser' - : 'Ctrl+click to open or Shift+Ctrl+click for default browser' + ? `${prefix}⌘+click to open in Orca` + : `${prefix}Ctrl+click to open in Orca` +} + +// Why: local HTML paths keep Shift+modifier as the system-browser shortcut. +export function getTerminalHtmlFileOpenHint(showActions = true): string { + const prefix = terminalLinkActionHintPrefix(showActions) + return isMacPlatform() + ? `${prefix}⌘+click to open, or ⇧⌘+click for default browser` + : `${prefix}Ctrl+click to open, or Shift+Ctrl+click for default browser` } export type TerminalUrlOpenHintOptions = { openLinksInApp?: boolean modifierInverts?: boolean + showActions?: boolean } // Why: openHttpLink only routes to Orca when the source is local, so a remote pane @@ -55,14 +64,15 @@ export function terminalUrlOpenHintOptionsFor( // it means "the other one" — so the hint has to name the actual destination. export function getTerminalUrlOpenHint(options: TerminalUrlOpenHintOptions = {}): string { const invertsToOrca = options.modifierInverts === true && options.openLinksInApp !== true + const prefix = terminalLinkActionHintPrefix(options.showActions !== false) if (invertsToOrca) { return isMacPlatform() - ? '⌘+click to open or ⇧⌘+click to open in Orca' - : 'Ctrl+click to open or Shift+Ctrl+click to open in Orca' + ? `${prefix}⌘+click to open, or ⇧⌘+click to open in Orca` + : `${prefix}Ctrl+click to open, or Shift+Ctrl+click to open in Orca` } return isMacPlatform() - ? '⌘+click to open or ⇧⌘+click for system browser' - : 'Ctrl+click to open or Shift+Ctrl+click for system browser' + ? `${prefix}⌘+click to open, or ⇧⌘+click for system browser` + : `${prefix}Ctrl+click to open, or Shift+Ctrl+click for system browser` } export function getTerminalUrlSystemBrowserHint(): string { @@ -75,12 +85,19 @@ export function getTerminalUrlOrcaBrowserHint(): string { return isMacPlatform() ? '⇧⌘+click to open in Orca' : 'Shift+Ctrl+click to open in Orca' } -export function getTerminalWorktreePathOpenHint(canOpenWithSystemDefault: boolean): string { +export function getTerminalWorktreePathOpenHint( + canOpenWithSystemDefault: boolean, + showActions = true +): string { + const prefix = terminalLinkActionHintPrefix(showActions) if (!canOpenWithSystemDefault) { - return isMacPlatform() ? '⌘+click to switch workspace' : 'Ctrl+click to switch workspace' + const directPrefix = showActions ? 'Click for actions or ' : '' + return isMacPlatform() + ? `${directPrefix}⌘+click to switch workspace` + : `${directPrefix}Ctrl+click to switch workspace` } return isMacPlatform() - ? '⌘+click to switch workspace or ⇧⌘+click to open in Finder' - : 'Ctrl+click to switch workspace or Shift+Ctrl+click to open folder' + ? `${prefix}⌘+click to switch workspace, or ⇧⌘+click to open in Finder` + : `${prefix}Ctrl+click to switch workspace, or Shift+Ctrl+click to open folder` } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts new file mode 100644 index 00000000000..a98486fc2ad --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom +import type { Terminal } from '@xterm/xterm' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { installTerminalLinkPointerGesture } from './terminal-link-pointer-gesture' + +const activeGestures = new Set>() + +function createGesture(): { + gesture: ReturnType + element: HTMLDivElement + setSelection: (selected: boolean) => void +} { + let selected = false + const element = document.createElement('div') + document.body.appendChild(element) + const terminal = { + element, + hasSelection: () => selected + } as unknown as Terminal + const gesture = installTerminalLinkPointerGesture(terminal) + activeGestures.add(gesture) + return { + gesture, + element, + setSelection: (next) => { + selected = next + } + } +} + +function mouse(type: string, x: number, y: number): MouseEvent { + return new MouseEvent(type, { bubbles: true, button: 0, clientX: x, clientY: y }) +} + +afterEach(() => { + for (const gesture of activeGestures) { + gesture.dispose() + } + activeGestures.clear() + document.body.replaceChildren() + vi.unstubAllGlobals() +}) + +describe('terminal link pointer gesture', () => { + it('allows a stationary plain click', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const { element, gesture } = createGesture() + const down = mouse('mousedown', 20, 30) + element.dispatchEvent(down) + + expect(gesture.canRequestAction(down)).toBe(true) + }) + + it('rejects a drag and a click that began with a selection', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const dragged = createGesture() + const dragDown = mouse('mousedown', 10, 10) + dragged.element.dispatchEvent(dragDown) + document.dispatchEvent(mouse('mousemove', 20, 10)) + expect(dragged.gesture.canRequestAction(dragDown)).toBe(false) + + const selected = createGesture() + selected.setSelection(true) + const selectionDown = mouse('mousedown', 10, 10) + selected.element.dispatchEvent(selectionDown) + selected.setSelection(false) + expect(selected.gesture.canRequestAction(selectionDown)).toBe(false) + }) + + it('rejects a selection created during the gesture and clears on blur', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const { element, gesture, setSelection } = createGesture() + const down = mouse('mousedown', 10, 10) + element.dispatchEvent(down) + setSelection(true) + expect(gesture.canRequestAction(down)).toBe(false) + + setSelection(false) + window.dispatchEvent(new Event('blur')) + expect(gesture.canRequestAction(down)).toBe(false) + }) + + it('removes its listeners on dispose', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const { element, gesture } = createGesture() + gesture.dispose() + const down = mouse('mousedown', 10, 10) + + element.dispatchEvent(down) + + expect(gesture.canRequestAction(down)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts new file mode 100644 index 00000000000..ff3ac6cf945 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts @@ -0,0 +1,68 @@ +import type { IDisposable, Terminal } from '@xterm/xterm' +import { isTerminalOwnedLinkGesture } from './terminal-link-activation' + +const DRAG_THRESHOLD_PX = 4 +const CAPTURE_LISTENER_OPTIONS = { capture: true } as const + +export type TerminalLinkPointerGesture = IDisposable & { + canRequestAction: (event: MouseEvent) => boolean +} + +type PendingGesture = { + clientX: number + clientY: number + hadSelection: boolean + moved: boolean +} + +export function installTerminalLinkPointerGesture(terminal: Terminal): TerminalLinkPointerGesture { + const terminalElement = terminal.element + const ownerDocument = terminalElement?.ownerDocument + const ownerWindow = ownerDocument?.defaultView + let pending: PendingGesture | null = null + + const clear = (): void => { + pending = null + } + const handleMouseDown = (event: MouseEvent): void => { + if (!isTerminalOwnedLinkGesture(event)) { + clear() + return + } + pending = { + clientX: event.clientX, + clientY: event.clientY, + hadSelection: terminal.hasSelection(), + moved: false + } + } + const handleMouseMove = (event: MouseEvent): void => { + if ( + pending && + Math.hypot(event.clientX - pending.clientX, event.clientY - pending.clientY) > + DRAG_THRESHOLD_PX + ) { + pending.moved = true + } + } + const handleMouseUp = (): void => { + queueMicrotask(clear) + } + + terminalElement?.addEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS) + ownerDocument?.addEventListener('mousemove', handleMouseMove) + ownerDocument?.addEventListener('mouseup', handleMouseUp) + ownerWindow?.addEventListener('blur', clear) + + return { + canRequestAction: () => + Boolean(pending && !pending.moved && !pending.hadSelection && !terminal.hasSelection()), + dispose: () => { + clear() + terminalElement?.removeEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS) + ownerDocument?.removeEventListener('mousemove', handleMouseMove) + ownerDocument?.removeEventListener('mouseup', handleMouseUp) + ownerWindow?.removeEventListener('blur', clear) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts index e595974bc18..a325056759b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts @@ -1,5 +1,5 @@ import type { IDisposable, Terminal } from '@xterm/xterm' -import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' +import { isTerminalLinkDirectActivation } from './terminal-link-activation' const CAPTURE_LISTENER_OPTIONS = { capture: true } as const @@ -31,17 +31,12 @@ export function installTerminalLinkPtyMouseSuppression( queueMicrotask(restore) } const handleMouseDown = (event: MouseEvent): void => { - if ( - event.button !== 0 || - !isTerminalHttpLinkActivation(event) || - !shouldSuppressMouseEvent(event) - ) { + if (!isTerminalLinkDirectActivation(event) || !shouldSuppressMouseEvent(event)) { return } restore() previousMouseEventsRequireAlt = Boolean(terminal.options.mouseEventsRequireAlt) - // Why: xterm otherwise forwards the same Cmd/Ctrl link gesture to a mouse-aware - // TUI, letting the terminal and the child process both open the URL. + // Why: an Orca-owned link gesture must not also reach a mouse-aware child TUI. terminal.options.mouseEventsRequireAlt = true ownerDocument?.addEventListener('mouseup', queueRestore) ownerWindow?.addEventListener('blur', restore) diff --git a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts index aa58f773680..4416e9ae47e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts @@ -124,7 +124,7 @@ describe('installTerminalLinkifierClickPriming', () => { expect(linkifier._activeLine).toBe(5) }) - it('ignores plain clicks and degrades safely when xterm internals are unavailable', () => { + it('primes plain action clicks and degrades safely when xterm internals are unavailable', () => { vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) const handleMouseMove = vi.fn() const present = createTerminal({ _handleMouseMove: handleMouseMove }) @@ -136,7 +136,7 @@ describe('installTerminalLinkifierClickPriming', () => { expect(() => getMouseDownHandler(absent.registrations)(modifierMouseDown({ metaKey: true })) ).not.toThrow() - expect(handleMouseMove).not.toHaveBeenCalled() + expect(handleMouseMove).toHaveBeenCalledOnce() }) it('removes its capture listener on dispose', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts index 7965fa488d4..ece9806e827 100644 --- a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts +++ b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts @@ -1,5 +1,5 @@ import type { IDisposable, Terminal } from '@xterm/xterm' -import { isTerminalLinkActivation } from './terminal-link-activation' +import { isTerminalOwnedLinkGesture } from './terminal-link-activation' const CAPTURE_LISTENER_OPTIONS = { capture: true } as const @@ -39,7 +39,7 @@ function primeTerminalLinkifier(terminal: Terminal, event: MouseEvent): void { export function installTerminalLinkifierClickPriming(terminal: Terminal): IDisposable { const terminalElement = terminal.element const handleMouseDown = (event: MouseEvent): void => { - if (event.button !== 0 || !isTerminalLinkActivation(event)) { + if (!isTerminalOwnedLinkGesture(event)) { return } // Why: xterm snapshots its current link on mousedown but otherwise resolves diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index 1f51187fd21..b041cb65f0d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -2,16 +2,32 @@ import { resolveTerminalFileLinkText } from '@/lib/terminal-links' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' import type { LinkHandlerDeps } from './terminal-link-handlers' import { resolveTerminalFileUrlTarget } from '../../../../shared/terminal-file-url-target' -import { openDetectedFilePath } from './terminal-file-open-routing' -import { isTerminalLinkActivation } from './terminal-link-activation' import { - openTerminalHttpLink, + isTerminalLinkActionActivation, + isTerminalLinkDirectActivation +} from './terminal-link-activation' +import { + handleTerminalHttpLink, + type TerminalHttpLinkActionDestinations, type TerminalLinkRoutingPreferenceRequester } from './terminal-url-link-hit-testing' import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' +import type { TerminalLinkActionContext } from './terminal-link-action-request' +import { handleTerminalFileLink } from './terminal-file-link-actions' type TerminalLinkEvent = Pick & - Partial> + Partial< + Pick< + MouseEvent, + | 'altKey' + | 'button' + | 'clientX' + | 'clientY' + | 'shiftKey' + | 'preventDefault' + | 'stopPropagation' + > + > function isDesktopOscLinkActivation(event: TerminalLinkEvent | undefined): boolean { if (!event) { @@ -22,7 +38,7 @@ function isDesktopOscLinkActivation(event: TerminalLinkEvent | undefined): boole } // Why: desktop xterm links must not open while the user is just placing the // cursor or selecting text. Mobile URL taps use a separate WebView path. - return isTerminalLinkActivation(event) + return isTerminalLinkDirectActivation(event) || isTerminalLinkActionActivation(event) } export function handleOscLink( @@ -37,20 +53,20 @@ export function handleOscLink( > & { sourceOwner?: HttpLinkSourceOwner requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester + linkActionContext?: TerminalLinkActionContext | null + actionDestinations?: TerminalHttpLinkActionDestinations } ): boolean { if (!isDesktopOscLinkActivation(event)) { return false } - // Why: xterm renders OSC 8 links as clickable anchors. Orca must suppress - // default anchor navigation so link-routing settings can choose the target. - // Note: we intentionally do NOT stopPropagation here — xterm's - // SelectionService listens for mouseup on ownerDocument to clear the - // pending drag-select state initiated by the mousedown of the same click. - // Stopping propagation leaves SelectionService's mousemove/mouseup handlers - // attached, so returning focus to the terminal and moving the mouse (even - // without holding a button) extends a selection until the next click/Esc. - event?.preventDefault?.() + const finish = (handled: boolean): boolean => { + if (handled) { + // Why: prevent anchor navigation without blocking xterm's document-level selection cleanup. + event?.preventDefault?.() + } + return handled + } const openDetectedPathLink = (): boolean => { const resolved = resolveTerminalFileLinkText( @@ -61,11 +77,17 @@ export function handleOscLink( if (!resolved) { return false } - openDetectedFilePath(resolved.absolutePath, resolved.line, resolved.column, { - ...deps, - openWithSystemDefault: Boolean(event?.shiftKey) - }) - return true + return finish( + handleTerminalFileLink( + resolved.absolutePath, + resolved.line, + resolved.column, + event as MouseEvent, + deps, + deps.linkActionContext, + rawText + ) + ) } if ( @@ -86,17 +108,20 @@ export function handleOscLink( } if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { - openTerminalHttpLink(parsed.toString(), { - worktreeId: deps.worktreeId, - sourceOwner: - deps.sourceOwner ?? - (deps.runtimeEnvironmentId - ? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId } - : { kind: 'local' }), - modifierHeld: Boolean(event?.shiftKey), - requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference - }) - return true + return finish( + handleTerminalHttpLink(parsed.toString(), event as MouseEvent, { + worktreeId: deps.worktreeId, + sourceOwner: + deps.sourceOwner ?? + (deps.runtimeEnvironmentId + ? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId } + : { kind: 'local' }), + requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference, + linkActionContext: deps.linkActionContext, + actionDestinations: deps.actionDestinations, + actionDestination: rawText + }) + ) } if (parsed.protocol === 'file:') { @@ -113,11 +138,17 @@ export function handleOscLink( if (!resolved) { return false } - openDetectedFilePath(resolved.filePath, resolved.line, resolved.column, { - ...deps, - openWithSystemDefault: Boolean(event?.shiftKey) - }) - return true + return finish( + handleTerminalFileLink( + resolved.filePath, + resolved.line, + resolved.column, + event as MouseEvent, + deps, + deps.linkActionContext, + rawText + ) + ) } return false } diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts index 4c5184cc933..0461474dedd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts @@ -134,14 +134,17 @@ function makeTerminal(options?: { } } -function mouseEventForRow(row: number, options: { altKey?: boolean } = {}): MouseEvent { +function mouseEventForRow( + row: number, + options: { altKey?: boolean; plain?: boolean } = {} +): MouseEvent { let defaultPrevented = false return { button: 0, - metaKey: true, + metaKey: !options.plain, ctrlKey: false, altKey: options.altKey ?? false, - shiftKey: true, + shiftKey: !options.plain, get defaultPrevented() { return defaultPrevented }, @@ -482,4 +485,51 @@ describe('hard-wrapped terminal HTTP clicks', () => { expect(terminal.options.mouseEventsRequireAlt).toBe(false) disposable.dispose() }) + + it('owns a plain HTTP link click without opening until an action is chosen', () => { + const request = vi.fn() + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { + worktreeId: 'wt-1', + getLinkActionContext: () => ({ + paneId: 1, + pointerGesture: { canRequestAction: () => true, dispose: vi.fn() }, + request, + focusTerminal: vi.fn() + }) + }) + const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1] + const mouseUp = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + const event = mouseEventForRow(0, { plain: true }) + + mouseDown!(event) + expect(terminal.options.mouseEventsRequireAlt).toBe(false) + mouseUp!(event) + + expect(request).toHaveBeenCalledWith(expect.objectContaining({ kind: 'url' })) + expect(openUrlMock).not.toHaveBeenCalled() + disposable.dispose() + }) + + it('does not suppress a plain HTTP link when action popovers are disabled', () => { + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { + worktreeId: 'wt-1', + getLinkActionContext: () => null + }) + const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1] + const mouseUp = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + const event = mouseEventForRow(0, { plain: true }) + + mouseDown!(event) + mouseUp!(event) + + expect(terminal.options.mouseEventsRequireAlt).toBe(false) + expect(openUrlMock).not.toHaveBeenCalled() + disposable.dispose() + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index d173157ea65..c28ae97824c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -9,6 +9,12 @@ import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer- import { extractTerminalHttpLinks } from './terminal-http-url-extraction' import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges' import { isTerminalLinkifierHoverActive } from '@/lib/pane-manager/terminal-linkifier-hover-reset' +import { translate } from '@/i18n/i18n' +import { isTerminalOwnedLinkGesture } from './terminal-link-activation' +import { + requestTerminalLinkAction, + type TerminalLinkActionContext +} from './terminal-link-action-request' export { extractTerminalHttpLinks } from './terminal-http-url-extraction' export { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' @@ -18,6 +24,10 @@ type UrlLinkHitTestDeps = { sourceOwner?: HttpLinkSourceOwner modifierHeld?: boolean requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester + linkActionContext?: TerminalLinkActionContext | null + actionDestinations?: TerminalHttpLinkActionDestinations + actionDestination?: string + forceDestination?: TerminalHttpLinkDestination } type UrlLinkClickFallbackDeps = { @@ -25,6 +35,15 @@ type UrlLinkClickFallbackDeps = { /** Resolved per click: the pane's PTY (and its runtime binding) may not exist at install time. */ getSourceOwner?: () => HttpLinkSourceOwner requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester + getLinkActionContext?: () => TerminalLinkActionContext | null + getActionDestinations?: () => TerminalHttpLinkActionDestinations +} + +export type TerminalHttpLinkDestination = 'orca' | 'system' + +export type TerminalHttpLinkActionDestinations = { + primary: TerminalHttpLinkDestination + alternate?: TerminalHttpLinkDestination } export type TerminalLinkRoutingPreferenceRequester = ( @@ -35,10 +54,73 @@ function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean { if (event.defaultPrevented || event.button !== 0) { return false } - // Why: desktop terminal links require an intentional Cmd/Ctrl gesture so - // plain clicks remain available for cursor placement and selection. Mobile - // tap routing is handled separately under mobile/src/terminal. - return isTerminalHttpLinkActivation(event) + // Why: Shift-only, Alt, and non-primary clicks remain available to the terminal or child TUI. + return isTerminalOwnedLinkGesture(event) +} + +export function handleTerminalHttpLink( + url: string, + event: MouseEvent | undefined, + deps: UrlLinkHitTestDeps +): boolean { + if (isTerminalHttpLinkActivation(event)) { + const forceDestination = event?.shiftKey + ? deps.actionDestinations?.alternate + : deps.actionDestinations?.primary + openTerminalHttpLink(url, { + ...deps, + modifierHeld: forceDestination ? false : Boolean(event?.shiftKey), + forceDestination + }) + return true + } + + const actionDestinations = deps.actionDestinations + const primaryDestination = actionDestinations?.primary + const labelForDestination = (destination: TerminalHttpLinkDestination): string => + destination === 'orca' + ? translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.orcaBrowser', + 'Orca Browser' + ) + : translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.systemBrowser', + 'System Browser' + ) + + return requestTerminalLinkAction(event, deps.linkActionContext, { + destination: deps.actionDestination ?? url, + kind: 'url', + primary: { + external: primaryDestination === 'system', + label: primaryDestination + ? labelForDestination(primaryDestination) + : translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.openLink', + 'Open link' + ), + run: () => + openTerminalHttpLink(url, { + ...deps, + modifierHeld: false, + forceDestination: primaryDestination + }) + }, + ...(actionDestinations?.alternate + ? { + alternate: { + external: actionDestinations.alternate === 'system', + label: labelForDestination(actionDestinations.alternate), + run: () => + openTerminalHttpLink(url, { + ...deps, + modifierHeld: false, + forceDestination: actionDestinations.alternate + }) + } + } + : {}) + }) } export function openHttpLinkAtTerminalMouseEvent( @@ -56,6 +138,19 @@ export function openHttpLinkAtTerminalMouseEvent( return openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, deps) } +export function findHttpLinkAtTerminalMouseEvent( + terminal: Terminal, + event: MouseEvent +): string | null { + if (event.button !== 0 || !isTerminalOwnedLinkGesture(event)) { + return null + } + const position = getTerminalBufferPositionForMouseEvent(terminal, event) + return position + ? findHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols) + : null +} + export function installHttpLinkClickFallback( terminal: Terminal, deps: UrlLinkClickFallbackDeps @@ -74,16 +169,19 @@ export function installHttpLinkClickFallback( return } - // Why: xterm's WebLinksAddon only activates after hover state exists. This - // direct mouseup fallback preserves modifier-clicks when the hover link was - // never established, while defaultPrevented avoids duplicate opens. - const opened = openHttpLinkAtTerminalMouseEvent(terminal, event, { - worktreeId: deps.worktreeId, - sourceOwner: deps.getSourceOwner?.() ?? { kind: 'local' }, - modifierHeld: event.shiftKey, - requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference - }) - if (opened) { + // Why: xterm's WebLinksAddon misses first clicks before hover state exists. + const url = findHttpLinkAtTerminalMouseEvent(terminal, event) + const handled = Boolean( + url && + handleTerminalHttpLink(url, event, { + worktreeId: deps.worktreeId, + sourceOwner: deps.getSourceOwner?.() ?? { kind: 'local' }, + requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference, + linkActionContext: deps.getLinkActionContext?.(), + actionDestinations: deps.getActionDestinations?.() + }) + ) + if (handled) { event.preventDefault() terminal.clearSelection() } @@ -161,6 +259,15 @@ export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): voi // Why: Orca browser tabs are local-only, so a link clicked in a runtime-hosted // pane must be classified by its pane's host, not the global active runtime. const sourceOwner = deps.sourceOwner ?? { kind: 'local' } + if (deps.forceDestination) { + openHttpLink(url, { + worktreeId: deps.worktreeId, + forceInApp: deps.forceDestination === 'orca', + forceSystemBrowser: deps.forceDestination === 'system', + sourceOwner + }) + return + } if (deps.modifierHeld) { // Why: the modifier states a destination outright, so it also skips the // one-time routing prompt; openHttpLink resolves which destination it means. diff --git a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts index f6d776eb289..441ff06ce86 100644 --- a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts +++ b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts @@ -1,12 +1,15 @@ import type { Terminal } from '@xterm/xterm' import type { LinkHandlerDeps } from './terminal-link-handlers' -import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' +import { isTerminalOwnedLinkGesture } from './terminal-link-activation' import { handleOscLink } from './terminal-osc-link-routing' import { - openHttpLinkAtTerminalMouseEvent, + findHttpLinkAtTerminalMouseEvent, + handleTerminalHttpLink, + type TerminalHttpLinkActionDestinations, type TerminalLinkRoutingPreferenceRequester } from './terminal-url-link-hit-testing' import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' +import type { TerminalLinkActionContext } from './terminal-link-action-request' type TerminalWebLinkClickDeps = Pick< LinkHandlerDeps, @@ -15,6 +18,8 @@ type TerminalWebLinkClickDeps = Pick< terminal: Terminal | null sourceOwner?: HttpLinkSourceOwner requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester + linkActionContext?: TerminalLinkActionContext | null + actionDestinations?: TerminalHttpLinkActionDestinations } export function handleTerminalWebLinkClick( @@ -22,28 +27,27 @@ export function handleTerminalWebLinkClick( event: MouseEvent | undefined, deps: TerminalWebLinkClickDeps ): boolean { - if (!event || !isTerminalHttpLinkActivation(event)) { + if (!event || !isTerminalOwnedLinkGesture(event)) { return false } let handled: boolean - if ( - deps.terminal && - openHttpLinkAtTerminalMouseEvent(deps.terminal, event, { + const completeUrl = deps.terminal ? findHttpLinkAtTerminalMouseEvent(deps.terminal, event) : null + if (completeUrl) { + handled = handleTerminalHttpLink(completeUrl, event, { worktreeId: deps.worktreeId, sourceOwner: deps.sourceOwner ?? (deps.runtimeEnvironmentId ? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId } : { kind: 'local' }), - modifierHeld: Boolean(event.shiftKey), - requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference + requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference, + linkActionContext: deps.linkActionContext, + actionDestinations: deps.actionDestinations }) - ) { // Why: WebLinksAddon only knows the physical row; Orca's logical hit-test // preserves the complete URL rendered across hard-wrapped TUI rows. event.preventDefault() - handled = true } else { handled = handleOscLink(url, event, deps) } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 631287b7217..1b666e20a80 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -39,9 +39,15 @@ import { handleOscLink } from './terminal-osc-link-routing' import { handleTerminalWebLinkClick } from './terminal-web-link-click' import { installHttpLinkClickFallback, + type TerminalHttpLinkActionDestinations, type TerminalLinkRoutingPreferenceRequester } from './terminal-url-link-hit-testing' import { installTerminalLinkifierClickPriming } from './terminal-linkifier-click-priming' +import { installTerminalLinkPointerGesture } from './terminal-link-pointer-gesture' +import type { + TerminalLinkActionContext, + TerminalLinkActionRequester +} from './terminal-link-action-request' import { resolveLocalhostHttpLinkDisplayUrl, type HttpLinkSourceOwner @@ -255,6 +261,7 @@ type UseTerminalPaneLifecycleDeps = { settings: GlobalSettings | null | undefined settingsRef: React.RefObject requestOpenLinksInAppPreference: TerminalLinkRoutingPreferenceRequester + requestTerminalLinkAction: TerminalLinkActionRequester /** Resolved Option-as-Alt: `'auto'` already mapped to `'true'|'false'` via the layout probe, which lives outside the settings store. */ effectiveMacOptionAsAlt: EffectiveMacOptionAsAlt effectiveMacOptionAsAltRef: React.RefObject @@ -594,6 +601,7 @@ export function useTerminalPaneLifecycle({ settings, settingsRef, requestOpenLinksInAppPreference, + requestTerminalLinkAction, effectiveMacOptionAsAlt, effectiveMacOptionAsAltRef, initialLayoutRef, @@ -656,6 +664,9 @@ export function useTerminalPaneLifecycle({ const linkProviderDisposablesRef = useRef(new Map()) const terminalHandleLinkDisposablesRef = useRef(new Map()) const linkifierClickPrimingDisposablesRef = useRef(new Map()) + const linkPointerGesturesRef = useRef( + new Map>() + ) const fileLinkClickFallbackDisposablesRef = useRef(new Map()) const httpLinkClickFallbackDisposablesRef = useRef(new Map()) // Why: read settingsRef at fire time so toggling "copy on select" applies without recreating panes. @@ -698,6 +709,7 @@ export function useTerminalPaneLifecycle({ const linkDisposables = linkProviderDisposablesRef.current const terminalHandleLinkDisposables = terminalHandleLinkDisposablesRef.current const linkifierClickPrimingDisposables = linkifierClickPrimingDisposablesRef.current + const linkPointerGestures = linkPointerGesturesRef.current const fileLinkClickFallbackDisposables = fileLinkClickFallbackDisposablesRef.current const httpLinkClickFallbackDisposables = httpLinkClickFallbackDisposablesRef.current const selectionDisposables = selectionDisposablesRef.current @@ -728,6 +740,32 @@ export function useTerminalPaneLifecycle({ resolvePaneLinkCwd(paneCwdRef.current, paneId, startupCwd) const getHttpLinkSourceOwnerForPane = (paneId: number) => resolveTerminalHttpLinkSourceOwner(paneTransportsRef.current.get(paneId)) + const getHttpLinkActionDestinations = (paneId: number): TerminalHttpLinkActionDestinations => { + const sourceOwner = getHttpLinkSourceOwnerForPane(paneId) + if (sourceOwner.kind !== 'local') { + return { primary: 'system' } + } + const options = terminalUrlOpenHintOptionsFor(settingsRef.current, sourceOwner) + return options.openLinksInApp + ? { primary: 'orca', alternate: 'system' } + : { primary: 'system', alternate: 'orca' } + } + const getLinkActionContext = (paneId: number): TerminalLinkActionContext | null => { + if (settingsRef.current?.terminalLinkActionPopoverEnabled === false) { + return null + } + const pane = managerRef.current?.getPanes().find((candidate) => candidate.id === paneId) + const pointerGesture = linkPointerGestures.get(paneId) + if (!pane || !pointerGesture) { + return null + } + return { + paneId, + pointerGesture, + request: requestTerminalLinkAction, + focusTerminal: () => pane.terminal.focus() + } + } // Why: lifecycle-scoped cache for cross-SSH/runtime existence probes; may hold temporarily stale entries. const pathExistsCache = new Map() const linkDeps: LinkHandlerDeps = { @@ -743,7 +781,8 @@ export function useTerminalPaneLifecycle({ getRuntimeEnvironmentIdForPane: (paneId) => { const sourceOwner = getHttpLinkSourceOwnerForPane(paneId) return sourceOwner.kind === 'runtime' ? sourceOwner.runtimeEnvironmentId : null - } + }, + getLinkActionContext } let resizeRaf: number | null = null const queueResizeAll = (focusActive: boolean): void => { @@ -847,9 +886,13 @@ export function useTerminalPaneLifecycle({ const fileOpenLinkHint = getTerminalFileOpenHint() // Why: read settingsRef at fire time so toggling link routing applies without recreating panes. const getUrlOpenLinkHint = (paneId: number): string => - getTerminalUrlOpenHint( - terminalUrlOpenHintOptionsFor(settingsRef.current, getHttpLinkSourceOwnerForPane(paneId)) - ) + getTerminalUrlOpenHint({ + ...terminalUrlOpenHintOptionsFor( + settingsRef.current, + getHttpLinkSourceOwnerForPane(paneId) + ), + showActions: settingsRef.current?.terminalLinkActionPopoverEnabled !== false + }) const osc7UncHost = extractUncHost(startupCwd) let releaseWebviewDragPassthrough: (() => void) | null = null @@ -1046,6 +1089,8 @@ export function useTerminalPaneLifecycle({ return !shouldBypass }) + const linkPointerGesture = installTerminalLinkPointerGesture(pane.terminal) + linkPointerGestures.set(pane.id, linkPointerGesture) const linkProviderDisposable = pane.terminal.registerLinkProvider( createFilePathLinkProvider(pane.id, linkDeps, pane.linkTooltip, fileOpenLinkHint) ) @@ -1057,7 +1102,8 @@ export function useTerminalPaneLifecycle({ ?.terminal ?? null, getRuntimeEnvironmentId: () => linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null, - linkTooltip: pane.linkTooltip + linkTooltip: pane.linkTooltip, + getLinkActionContext: () => getLinkActionContext(pane.id) }) ) terminalHandleLinkDisposablesRef.current.set(pane.id, terminalHandleLinkDisposable) @@ -1072,7 +1118,9 @@ export function useTerminalPaneLifecycle({ const httpLinkClickFallbackDisposable = installHttpLinkClickFallback(pane.terminal, { ...linkDeps, getSourceOwner: () => getHttpLinkSourceOwnerForPane(pane.id), - requestOpenLinksInAppPreference + requestOpenLinksInAppPreference, + getLinkActionContext: () => getLinkActionContext(pane.id), + getActionDestinations: () => getHttpLinkActionDestinations(pane.id) }) httpLinkClickFallbackDisposables.set(pane.id, httpLinkClickFallbackDisposable) seedStartupSessionRestoredBanner(ptyDeps.startup, pane.id, onShowSessionRestoredBanner) @@ -1142,7 +1190,9 @@ export function useTerminalPaneLifecycle({ startupCwd: getPaneLinkCwd(pane.id), runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null, sourceOwner: getHttpLinkSourceOwnerForPane(pane.id), - requestOpenLinksInAppPreference + requestOpenLinksInAppPreference, + linkActionContext: getLinkActionContext(pane.id), + actionDestinations: getHttpLinkActionDestinations(pane.id) }) // Why: link activation can steal focus before the click's mouseup reaches xterm, stranding its drag-select // listener (runaway selection until next click/Esc); clearSelection detaches it (SelectionService._removeMouseDownListeners). @@ -1216,6 +1266,11 @@ export function useTerminalPaneLifecycle({ linkifierClickPrimingDisposable.dispose() linkifierClickPrimingDisposablesRef.current.delete(paneId) } + const linkPointerGesture = linkPointerGestures.get(paneId) + if (linkPointerGesture) { + linkPointerGesture.dispose() + linkPointerGestures.delete(paneId) + } const fileLinkClickFallbackDisposable = fileLinkClickFallbackDisposablesRef.current.get(paneId) if (fileLinkClickFallbackDisposable) { @@ -1459,8 +1514,10 @@ export function useTerminalPaneLifecycle({ }, terminalTuiScrollSensitivity: () => normalizeTerminalTuiMouseWheelMultiplier(settingsRef.current?.terminalTuiScrollSensitivity), - onLinkClick: (event, url) => { - const activePane = managerRef.current?.getActivePane() + onLinkClick: (paneId, event, url) => { + const activePane = managerRef.current + ?.getPanes() + .find((candidate) => candidate.id === paneId) handleTerminalWebLinkClick(url, event, { ...linkDeps, terminal: activePane?.terminal ?? null, @@ -1471,7 +1528,9 @@ export function useTerminalPaneLifecycle({ sourceOwner: activePane ? getHttpLinkSourceOwnerForPane(activePane.id) : { kind: 'local' }, - requestOpenLinksInAppPreference + requestOpenLinksInAppPreference, + linkActionContext: getLinkActionContext(paneId), + actionDestinations: getHttpLinkActionDestinations(paneId) }) }, linkOpenHint: getUrlOpenLinkHint, @@ -1713,6 +1772,10 @@ export function useTerminalPaneLifecycle({ disposable.dispose() } linkifierClickPrimingDisposables.clear() + for (const gesture of linkPointerGestures.values()) { + gesture.dispose() + } + linkPointerGestures.clear() for (const disposable of fileLinkClickFallbackDisposables.values()) { disposable.dispose() } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3e0980342eb..3184344d835 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2839,6 +2839,19 @@ "cc5a2dc706": "Edit pane title: {{value0}}", "7dbbfcbecc": "Pane title" }, + "TerminalLinkActionPopover": { + "openLink": "Open link", + "openFile": "Open file", + "switchWorkspace": "Switch workspace", + "openInFinder": "Open in Finder", + "openFolder": "Open folder", + "openWithDefaultApp": "Open with default app", + "switchTerminal": "Switch terminal", + "openTaskTerminal": "Open task terminal", + "systemBrowser": "System Browser", + "orcaBrowser": "Orca Browser", + "terminalLinkSettings": "Terminal link settings" + }, "TerminalSessionStateSaveFailureDialog": { "6bee0c8f17": "Open Disk Space Analyzer", "ae20d0ffc2": "Dismiss", @@ -8110,6 +8123,14 @@ "invert": "invert", "opposite": "opposite" }, + "terminalLinkActions": { + "terminal": "terminal", + "click": "click", + "actions": "actions", + "popover": "popover", + "menu": "menu", + "disable": "disable" + }, "72c58f7792": "webview", "82ba1c80ea": "localhost", "bea27bac4b": "links", @@ -10023,6 +10044,10 @@ "descriptionSystem": "Links open in Orca, so {{chord}}+click sends one to your system browser instead.", "descriptionOrca": "Links open in your system browser. When enabled, {{chord}}+click opens one in Orca's built-in browser instead." }, + "BrowserTerminalLinkActionsSetting": { + "title": "Show terminal link actions", + "description": "Show available actions when you click a terminal link. Turn this off to require {{modifier}}-click." + }, "PluginConsentDialog": { "workerTrust": "Background worker — runs its own process", "instructionalTrust": "Instructional content — runs later under user or agent authority", diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index d0c96799978..64991900a25 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -103,6 +103,34 @@ describe('openHttpLink', () => { expect(createBrowserTabMock).not.toHaveBeenCalled() }) + it('forceInApp opens a local link in Orca when the setting is off', () => { + storeState.settings = { openLinksInApp: false } + + openHttpLink('https://example.com/', { + worktreeId: 'wt-1', + forceInApp: true, + sourceOwner: { kind: 'local' } + }) + + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true + }) + expect(openUrlMock).not.toHaveBeenCalled() + }) + + it('does not force a remote link into the Orca browser', () => { + storeState.settings = { openLinksInApp: false } + + openHttpLink('https://example.com/', { + worktreeId: 'wt-1', + forceInApp: true, + sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' } + }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) + it('routes to the system browser when a remote runtime environment is active', () => { storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'env-1' } diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index 86e4f91fcd5..60f53457fc7 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -10,6 +10,8 @@ export type OpenHttpLinkOptions = { worktreeId?: string | null /** Unconditional: always use the system browser regardless of settings. */ forceSystemBrowser?: boolean + /** Unconditional for local sources: open inside Orca regardless of settings. */ + forceInApp?: boolean /** The Shift escape-hatch modifier was held; resolveModifierRouting decides what it means. */ modifierHeld?: boolean sourceOwner?: HttpLinkSourceOwner @@ -91,7 +93,7 @@ export function resolveModifierRouting( } export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void { - const { worktreeId, forceSystemBrowser, modifierHeld, sourceOwner } = opts + const { worktreeId, forceSystemBrowser, forceInApp, modifierHeld, sourceOwner } = opts if (sourceOwner?.kind === 'unknown') { return } @@ -109,7 +111,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void !forceSystemBrowser && !modifier.wantsSystemBrowser && Boolean(worktreeId) && - (openLinksInApp || modifier.wantsOrca) + (forceInApp || openLinksInApp || modifier.wantsOrca) if (routeToOrca && worktreeId && state) { // Why: http clicks from inside a worktree should not push a worktree-switch diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts index 64cbdd6ada1..fd0ff5e88ec 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts @@ -4,6 +4,7 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { createPaneDOM } from './pane-dom-creation' const webLinksAddonMock = vi.hoisted(() => ({ + handler: null as ((event: MouseEvent, uri: string) => void) | null, options: null as { hover?: (event: MouseEvent, uri: string) => void; leave?: () => void } | null })) @@ -32,7 +33,8 @@ vi.mock('@xterm/addon-unicode11', () => ({ })) vi.mock('@xterm/addon-web-links', () => ({ - WebLinksAddon: vi.fn().mockImplementation(function WebLinksAddon(_handler, options) { + WebLinksAddon: vi.fn().mockImplementation(function WebLinksAddon(handler, options) { + webLinksAddonMock.handler = handler webLinksAddonMock.options = options return {} }) @@ -135,4 +137,23 @@ describe('createPaneDOM link tooltips', () => { expect(linkOpenHint).toHaveBeenCalledWith(7) expect(formatLinkTooltip).toHaveBeenCalledWith(7, 'http://localhost:5180/', 'open hint') }) + + it('identifies the clicked pane to link routing', () => { + const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + const onLinkClick = vi.fn() + createPaneDOM( + 7, + leafId, + { linkOpenHint: () => 'open hint', onLinkClick }, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + const event = {} as MouseEvent + + webLinksAddonMock.handler?.(event, 'https://example.com') + + expect(onLinkClick).toHaveBeenCalledWith(7, event, 'https://example.com') + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 0163587662e..f383dfa3c53 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -70,7 +70,7 @@ export function createPaneDOM( const paneDragCleanup = attachPaneDrag(dragHandle, id, dragState, dragCallbacks) const webLinksAddon = new WebLinksAddon( - options.onLinkClick ? (event, uri) => options.onLinkClick!(event, uri) : undefined, + options.onLinkClick ? (event, uri) => options.onLinkClick!(id, event, uri) : undefined, { hover: (_event, uri) => { if (uri) { diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 21956fad0ec..113f10af80d 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -58,7 +58,7 @@ export type PaneManagerOptions = { onExternalPaneDrop?: PaneExternalDropHandler terminalOptions?: (paneId: number) => Partial terminalTuiScrollSensitivity?: () => number | undefined - onLinkClick?: (event: MouseEvent | undefined, url: string) => void + onLinkClick?: (paneId: number, event: MouseEvent | undefined, url: string) => void /** Resolved per hover so link-routing setting changes apply without recreating panes. */ // Why: required so dropping the wiring is a compile error — an optional hint with a // default would silently serve stale copy that no test can distinguish. diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 1079a47f0f7..b901ffb0cce 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -60,6 +60,7 @@ const SETTINGS_NAV_INTENT_SET: ReadonlySet = new Set(SETTINGS_NAV_INTENT export type SettingsNavTarget = (typeof SETTINGS_NAV_TARGETS)[number] export const FULL_DISK_ACCESS_SETTINGS_TARGET_ID = 'developer-permissions-full-disk-access' +export const BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID = 'browser-terminal-link-actions' export type SettingsNavigationTarget = { pane: SettingsNavTarget diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index f3a6ff1b89e..ea41c8a6af9 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -69,6 +69,10 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').showMenuBarIcon).toBe(true) }) + it('shows terminal link actions by default', () => { + expect(getDefaultSettings('/tmp').terminalLinkActionPopoverEnabled).toBe(true) + }) + it('confirms before closing pinned tabs by default', () => { expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index fcd72044911..d9a17c103f5 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -265,6 +265,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { localhostWorktreeLabelsEnabled: false, openLinksInAppPreferencePrompted: false, openLinksInAppModifierInverts: false, + terminalLinkActionPopoverEnabled: true, openAgentTabsInChatByDefault: false, experimentalNativeChat: false, nativeChatSessionOptions: {}, diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 6115584fd04..bbffedaefe0 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -246,6 +246,7 @@ export const SETTINGS_CHANGED_WHITELIST = [ 'editorAutoSave', 'openLinksInApp', 'openLinksInAppModifierInverts', + 'terminalLinkActionPopoverEnabled', 'experimentalMobile', 'experimentalPet', 'experimentalNativeChat', diff --git a/src/shared/types.ts b/src/shared/types.ts index 63019fcd03d..c6dfff01d3c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2878,6 +2878,8 @@ export type GlobalSettings = { openLinksInAppPreferencePrompted: boolean /** Opt-in: Shift+modifier click inverts openLinksInApp instead of always forcing the system browser. Off keeps the historical one-way escape hatch. */ openLinksInAppModifierInverts?: boolean + /** Show terminal link actions on plain click; off restores modifier-click-only terminal links. */ + terminalLinkActionPopoverEnabled?: boolean /** Opt-in: open new coding-agent tabs in native chat instead of the raw terminal; optional for legacy settings. */ openAgentTabsInChatByDefault?: boolean /** Experimental native chat surface for Claude/Codex sessions; off by default. */ From bdd763188fc79a94a3325e8e72c81e957a331bfc Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 9 Aug 2026 19:52:16 -0700 Subject: [PATCH 021/271] Fix Windows AI Vault resumes using the wrong shell (#13420) * fix: use project shell for AI Vault resumes * fix: preserve WSL cwd fallback semantics --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/ipc/pty.test.ts | 276 ++++++++++++++++++ src/main/ipc/pty.ts | 66 ++++- src/main/providers/local-pty-provider.test.ts | 25 ++ src/main/providers/local-pty-provider.ts | 4 +- src/main/wsl.test.ts | 96 +++++- src/main/wsl.ts | 62 +++- .../tab-group/AiVaultSessionDropLayer.tsx | 1 + .../src/lib/ai-vault-omp-cold-resume.test.ts | 4 +- ...ai-vault-resume-command.drop-repin.test.ts | 2 +- .../src/lib/ai-vault-resume-command.test.ts | 76 +++-- .../src/lib/ai-vault-resume-command.ts | 68 ++--- src/renderer/src/lib/ai-vault-resume-shell.ts | 72 +++++ .../src/lib/launch-ai-vault-session.test.ts | 8 +- .../src/lib/launch-ai-vault-session.ts | 7 +- 14 files changed, 658 insertions(+), 109 deletions(-) create mode 100644 src/renderer/src/lib/ai-vault-resume-shell.ts diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 05cafc81785..010f35f436e 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -16,6 +16,7 @@ import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-a import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' +import type * as Wsl from '../wsl' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it @@ -57,6 +58,7 @@ const { piBuildPtyEnvMock, piClearPtyMock, isPwshAvailableMock, + wslUncDirectoryExistsAsyncMock, trackMock, classifyErrorMock, registerPtyMock, @@ -86,6 +88,7 @@ const { openCodeBuildPtyEnvMock: vi.fn(), mimoCodeBuildPtyEnvMock: vi.fn(), isPwshAvailableMock: vi.fn(), + wslUncDirectoryExistsAsyncMock: vi.fn(), openCodeClearPtyMock: vi.fn(), buildAgentHookEnvMock: vi.fn(), clearAgentHookPaneStateMock: vi.fn(), @@ -183,6 +186,11 @@ vi.mock('../pwsh', () => ({ isPwshAvailableAsync: isPwshAvailableMock })) +vi.mock('../wsl', async (importOriginal) => ({ + ...(await importOriginal()), + wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) +})) + vi.mock('../telemetry/client', () => ({ track: trackMock })) @@ -384,6 +392,8 @@ describe('registerPtyHandlers', () => { piBuildPtyEnvMock.mockReset() piClearPtyMock.mockReset() isPwshAvailableMock.mockReset() + wslUncDirectoryExistsAsyncMock.mockReset() + wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) trackMock.mockReset() classifyErrorMock.mockReset() registerPtyMock.mockReset() @@ -12125,6 +12135,272 @@ describe('registerPtyHandlers', () => { expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) }) + it('keeps an existing POSIX startup cwd for the selected WSL runtime', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(true) + statSyncMock.mockImplementation((target: string) => { + if (target === '/home/alice/repo') { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:\\Users\\alice\\repo', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo-1:wsl' + } + } + }) + + expect(statSyncMock).not.toHaveBeenCalledWith('/home/alice/repo') + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith( + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo' + ) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/home/alice/repo', shellOverride: 'wsl.exe' }) + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('falls back when the selected WSL runtime reports a POSIX cwd missing', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-missing-cwd' }) + const worktreePath = 'C:/Users/alice/repo' + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(false) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/deleted', + cwdFallback: 'worktree', + worktreeId: `repo-1::${worktreePath}`, + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo-1:wsl' + } + } + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: worktreePath })) + expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves a POSIX WSL cwd when the distro probe is inconclusive', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-inconclusive-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:/Users/alice/repo', + shellOverride: 'wsl.exe' + })) as { startupCwdFallback?: unknown } + + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/home/alice/repo' }) + ) + expect(result.startupCwdFallback).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves a POSIX cwd when WSL owns it but no distro can be resolved', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-no-distro-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + _setWslCachesForTests({ available: true, distros: [] }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:/Users/alice/repo', + shellOverride: 'wsl.exe' + })) as { startupCwdFallback?: unknown } + + expect(statSyncMock).not.toHaveBeenCalledWith('/home/alice/repo') + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/home/alice/repo' }) + ) + expect(result.startupCwdFallback).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it.each([ + { exists: true, expectedCwd: '/home/alice/repo', expectedFallback: undefined }, + { + exists: false, + expectedCwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice', + expectedFallback: { + kind: 'worktree', + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice' + } + } + ])('resolves POSIX cwd existence for a WSL UNC workspace ($exists)', async (testCase) => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-unc-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(testCase.exists) + if (!testCase.exists) { + wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(true) + } + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice' + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: testCase.expectedCwd }) + ) + expect(result.startupCwdFallback).toEqual(testCase.expectedFallback) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves a missing POSIX cwd when its WSL workspace root is also missing', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-missing-root' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(false) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/deleted', + cwdFallback: 'worktree', + worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice' + })) as { startupCwdFallback?: unknown } + + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(2) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/home/alice/deleted' }) + ) + expect(result.startupCwdFallback).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('validates a /mnt drive cwd through its native Windows path', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-mnt-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/mnt/c/Users/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:/Users/alice/repo', + shellOverride: 'wsl.exe' + }) + + expect(statSyncMock).toHaveBeenCalledWith('C:\\Users\\alice\\repo') + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/mnt/c/Users/alice/repo' }) + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('still falls back a missing Windows cwd when the selected runtime is WSL', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-windows-cwd' }) + const worktreePath = 'C:/Users/alice/repo' + const missingCwd = `${worktreePath}/deleted-folder` + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + statSyncMock.mockImplementation((target: string) => { + if (target === missingCwd) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: missingCwd, + cwdFallback: 'worktree', + worktreeId: `repo-1::${worktreePath}`, + shellOverride: 'wsl.exe' + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: worktreePath, shellOverride: 'wsl.exe' }) + ) + expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('keeps a missing cwd unchanged without the fallback flag', async () => { registerPtyHandlers(mainWindow as never) existsSyncMock.mockImplementation((target: string) => target !== '/repo/app/deleted-folder') diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 219829c619e..3ff4e36fe1d 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -139,7 +139,7 @@ import { resolveTerminalStartupCwdForWorkspace, type TerminalStartupCwdMissingDirFallback } from '../../shared/terminal-startup-cwd' -import { isWslUncPath } from '../../shared/wsl-paths' +import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { @@ -150,7 +150,7 @@ import { clearMigrationUnsupportedPty, clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state' -import { parseWslPath } from '../wsl' +import { parseWslPath, wslUncDirectoryExistsAsync } from '../wsl' import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-environment-path' import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env' import { PtyProducerFlowController } from './pty-producer-flow-control' @@ -5775,15 +5775,73 @@ export function registerPtyHandlers( await startupPromise } // Why: honor the fallback only for fresh local spawns — reattach needs exact cwd and SSH can't probe the local filesystem. - const allowMissingCwdFallback = + const requestedMissingCwdFallback = !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' + const isWslOwnedPosixCwd = + args.cwd?.startsWith('/') === true && !/^\/[A-Za-z](?:\/|$)/.test(args.cwd) + const startupWorkspaceCwd = + requestedMissingCwdFallback && isWslOwnedPosixCwd + ? resolvePtySpawnStartupCwd(args.worktreeId, '.') + : undefined + const initiallyResolvedStartupCwd = + requestedMissingCwdFallback && isWslOwnedPosixCwd + ? resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) + : undefined + const startupTerminalRuntimeOptions = + requestedMissingCwdFallback && process.platform === 'win32' + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings: getSettings?.(), + projectRuntime: args.projectRuntime, + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : undefined + const wslRuntimeOwnsStartupCwd = + requestedMissingCwdFallback && + isWslOwnedPosixCwd && + (isWslShellName(startupTerminalRuntimeOptions?.shellOverride) || + isWslUncPath(startupWorkspaceCwd ?? '')) + const startupWslContext = wslRuntimeOwnsStartupCwd + ? resolveWslSessionContext({ + cwd: startupWorkspaceCwd, + shellOverride: startupTerminalRuntimeOptions?.shellOverride, + terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro + }) + : undefined + let wslStartupCwdExists: boolean | null = null + let wslWorkspaceCwdExists: boolean | null = null + if (startupWslContext && initiallyResolvedStartupCwd) { + const validationCwd = toWindowsWslPath( + initiallyResolvedStartupCwd, + startupWslContext.distro + ) + wslStartupCwdExists = isWslUncPath(validationCwd) + ? await wslUncDirectoryExistsAsync(validationCwd) + : localStartupCwdDirectoryExists(validationCwd) + if (wslStartupCwdExists === false && startupWorkspaceCwd) { + wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd) + ? await wslUncDirectoryExistsAsync(startupWorkspaceCwd) + : localStartupCwdDirectoryExists(startupWorkspaceCwd) + } + } + const allowMissingCwdFallback = + requestedMissingCwdFallback && + (!wslRuntimeOwnsStartupCwd || + (wslStartupCwdExists === false && wslWorkspaceCwdExists === true)) let didFallbackToWorkspaceRootCwd = false const cwd = resolvePtySpawnStartupCwd( args.worktreeId, args.cwd, allowMissingCwdFallback ? { - directoryExists: localStartupCwdDirectoryExists, + directoryExists: (path) => + startupWslContext && + wslStartupCwdExists === false && + path === initiallyResolvedStartupCwd + ? false + : startupWslContext && path === startupWorkspaceCwd + ? wslWorkspaceCwdExists === true + : localStartupCwdDirectoryExists(path), onFallbackToWorkspaceRoot: () => { didFallbackToWorkspaceRootCwd = true } diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 5567c11c957..a7b675e78da 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -978,6 +978,31 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian') }) + it('translates a POSIX cwd through the preferred WSL distro', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + await provider.spawn({ + cols: 80, + rows: 24, + worktreeId: 'repo-1::C:\\Users\\jin\\repo', + cwd: '/home/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', + '--', + 'sh', + '-c', + expect.stringContaining("cd '/home/jin/repo'") + ]) + expect(spawnCall[2].cwd).not.toBe('/home/jin/repo') + }) + it('resolves and persists the default distro for Windows cwd WSL terminals', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const buildSpawnEnv = vi.fn( diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 6ed3c9f0f87..d1ba44e5f2e 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -239,9 +239,9 @@ function getWslContextFromWorktreeId( */ function getWslContextFromPreferredDistro( distro: string | null | undefined -): { distro: string } | undefined { +): { distro: string; treatPosixCwdAsWsl: true } | undefined { const trimmed = distro?.trim() - return trimmed ? { distro: trimmed } : undefined + return trimmed ? { distro: trimmed, treatPosixCwdAsWsl: true } : undefined } /** diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index cd77d761caf..719368e3af4 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -29,7 +29,8 @@ import { parseWslPath, toLinuxPath, toWindowsWslPath, - wslUncDirectoryExists + wslUncDirectoryExists, + wslUncDirectoryExistsAsync } from './wsl' function withPlatform(value: NodeJS.Platform, fn: () => T): T { @@ -805,36 +806,39 @@ describe('wslUncDirectoryExists', () => { }) it('returns true when the distro reports the directory exists', () => { - execFileSyncMock.mockReturnValue('') + execFileSyncMock.mockReturnValue('__ORCA_DIRECTORY_EXISTS__') const result = withPlatform('win32', () => wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') ) expect(result).toBe(true) expect(execFileSyncMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'test', '-d', '/home/jin/repo'], + [ + '-d', + 'Ubuntu', + '--', + 'sh', + '-c', + expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), + 'sh', + '/home/jin/repo' + ], expect.objectContaining({ timeout: 5000 }) ) }) - it('returns false when test -d exits non-zero (directory missing)', () => { - execFileSyncMock.mockImplementation(() => { - // Why: child_process surfaces a non-zero exit as an Error with `status`. - const error = new Error('Command failed') as Error & { status: number } - error.status = 1 - throw error - }) + it('returns false when the guest reports the directory missing', () => { + execFileSyncMock.mockReturnValue('__ORCA_DIRECTORY_MISSING__') const result = withPlatform('win32', () => wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') ) expect(result).toBe(false) }) - it('returns null when wsl.exe is unavailable (inconclusive)', () => { + it('returns null when wsl.exe or the distro is unavailable', () => { execFileSyncMock.mockImplementation(() => { - // No numeric `status` -> spawn failure (ENOENT), not a missing directory. - const error = new Error('spawn wsl.exe ENOENT') as Error & { code: string } - error.code = 'ENOENT' + const error = new Error('distro unavailable') as Error & { status: number } + error.status = 4294967295 throw error }) const result = withPlatform('win32', () => @@ -851,3 +855,67 @@ describe('wslUncDirectoryExists', () => { expect(execFileSyncMock).not.toHaveBeenCalled() }) }) + +describe('wslUncDirectoryExistsAsync', () => { + afterEach(() => { + execFileMock.mockReset() + }) + + it('returns true when the distro reports the directory exists', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => + callback(null, '__ORCA_DIRECTORY_EXISTS__') + ) + + await expect( + withPlatformAsync('win32', () => + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') + ) + ).resolves.toBe(true) + expect(execFileMock).toHaveBeenCalledWith( + 'wsl.exe', + [ + '-d', + 'Ubuntu', + '--', + 'sh', + '-c', + expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), + 'sh', + '/home/jin/repo' + ], + expect.objectContaining({ timeout: 5000 }), + expect.any(Function) + ) + }) + + it('distinguishes a missing directory from an inconclusive probe', async () => { + execFileMock + .mockImplementationOnce((_command, _args, _options, callback) => + callback(null, '__ORCA_DIRECTORY_MISSING__') + ) + .mockImplementationOnce((_command, _args, _options, callback) => + callback(Object.assign(new Error('distro unavailable'), { code: 4294967295 }), '') + ) + + await withPlatformAsync('win32', async () => { + await expect( + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') + ).resolves.toBe(false) + await expect( + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') + ).resolves.toBeNull() + }) + }) + + it('returns null without spawning for paths outside WSL or off Windows', async () => { + await expect( + withPlatformAsync('win32', () => wslUncDirectoryExistsAsync('C:\\Users\\jin\\repo')) + ).resolves.toBeNull() + await expect( + withPlatformAsync('linux', () => + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin') + ) + ).resolves.toBeNull() + expect(execFileMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/wsl.ts b/src/main/wsl.ts index e415a3c937f..8311170faeb 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -21,6 +21,33 @@ export type WslPathInfo = { linuxPath: string } +const WSL_DIRECTORY_EXISTS_MARKER = '__ORCA_DIRECTORY_EXISTS__' +const WSL_DIRECTORY_MISSING_MARKER = '__ORCA_DIRECTORY_MISSING__' + +function getWslDirectoryProbeArgs(info: WslPathInfo): string[] { + return [ + '-d', + info.distro, + '--', + 'sh', + '-c', + `if [ -d "$1" ]; then printf ${WSL_DIRECTORY_EXISTS_MARKER}; else printf ${WSL_DIRECTORY_MISSING_MARKER}; fi`, + 'sh', + info.linuxPath + ] +} + +function parseWslDirectoryProbeOutput(stdout: unknown): boolean | null { + const output = String(stdout) + if (output.includes(WSL_DIRECTORY_EXISTS_MARKER)) { + return true + } + if (output.includes(WSL_DIRECTORY_MISSING_MARKER)) { + return false + } + return null +} + /** * Detect if a Windows path is a WSL UNC path and extract the distro name * and equivalent Linux path. @@ -49,8 +76,8 @@ export function isWslPath(path: string): boolean { * Why: Win32 fs.statSync against the WSL 9P filesystem (\\wsl.localhost\...) * is unreliable for repos that live on the WSL side — it can report ENOENT for * directories that exist, which made opening a WSL worktree fail with - * "Working directory ... does not exist". `wsl.exe -d test -d` asks - * the distro directly, which is the authoritative answer. Returns null (rather + * "Working directory ... does not exist". The guest marker probe asks the + * distro directly, which is the authoritative answer. Returns null (rather * than false) when wsl.exe is unavailable or errors so callers can fall back to * the fs check instead of falsely rejecting a valid directory. */ @@ -63,22 +90,33 @@ export function wslUncDirectoryExists(uncPath: string): boolean | null { return null } try { - execFileSync('wsl.exe', ['-d', info.distro, '--', 'test', '-d', info.linuxPath], { + const stdout = execFileSync('wsl.exe', getWslDirectoryProbeArgs(info), { stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 + timeout: 5000, + encoding: 'utf8' }) - return true - } catch (error) { - // A non-zero exit (directory missing) surfaces as an error with a numeric - // `status`; treat that as a definitive "does not exist". Any other failure - // (wsl.exe missing, distro not running, timeout) is inconclusive -> null. - if (typeof (error as { status?: unknown })?.status === 'number') { - return false - } + return parseWslDirectoryProbeOutput(stdout) + } catch { return null } } +export function wslUncDirectoryExistsAsync(uncPath: string): Promise { + if (process.platform !== 'win32') { + return Promise.resolve(null) + } + const info = parseWslUncPath(uncPath) + if (!info) { + return Promise.resolve(null) + } + return new Promise((resolve) => { + execFile('wsl.exe', getWslDirectoryProbeArgs(info), { timeout: 5000 }, (_error, stdout) => { + // Why: wsl.exe uses numeric exits for both guest results and host failures; only the guest marker is authoritative. + resolve(parseWslDirectoryProbeOutput(stdout)) + }) + }) +} + /** * Convert a Windows path to a Linux path for commands that will execute inside WSL. * Returns the path unchanged if it is already POSIX-style. diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index 3c9ddd26369..a55aed7e886 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -260,6 +260,7 @@ export default function AiVaultSessionDropLayer({ agent: payload.agent, worktreeId, command: startup.command, + ...(payload.sessionCwd ? { cwd: payload.sessionCwd } : {}), ...(startup.env ? { env: startup.env } : {}), ...(startup.envToDelete ? { envToDelete: startup.envToDelete } : {}), ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), diff --git a/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts b/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts index f6e17312ae4..256858d3ad2 100644 --- a/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts +++ b/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts @@ -39,8 +39,8 @@ describe('AI Vault OMP cold resume', () => { }) expect(startup).toMatchObject({ - command: - "cd '/repo' && omp '--model' 'custom' --resume '/custom/omp-sessions/project/session.jsonl'", + command: "omp '--model' 'custom' --resume '/custom/omp-sessions/project/session.jsonl'", + cwd: '/repo', env: { OMP_PROFILE: 'custom' }, launchConfig: { agentCommand: "omp '--model' 'custom'", diff --git a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts index 6a0e64d2b04..139b4e3fd97 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts @@ -64,7 +64,7 @@ describe('buildAiVaultDropRepinStartup', () => { expect(startup).not.toBeNull() expect(startup?.command).toContain(`CODEX_HOME='${SELECTED_HOME}'`) expect(startup?.command).not.toContain(RECORDED_HOME) - expect(startup?.command).toContain("cd '/Users/ada/repo' && ") + expect(startup).toMatchObject({ cwd: '/Users/ada/repo' }) }) it('repins a payload whose session has no cwd instead of keeping the wrong-account command', () => { diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 9ad997974f5..f89f4054bf4 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -69,6 +69,30 @@ function buildQueuedAiVaultResumeCommand( } describe('ai vault resume command runtime', () => { + it('repro: queues a host-runtime resume without configured-WSL shell syntax', () => { + const state = makeState({ + worktreePath: 'C:\\Users\\alice\\repo', + localWindowsRuntimePreference: { kind: 'windows-host' }, + terminalWindowsShell: 'wsl.exe' + }) + + expect( + buildAiVaultResumeStartupForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'claude', + sessionId: 'session one', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toMatchObject({ + command: "claude '--resume' 'session one'", + cwd: 'C:\\Users\\alice\\repo' + }) + }) + it('queues a PowerShell-valid command for the default Windows shell', () => { // Why: the queued command is typed into the live tab shell (default // PowerShell), which mis-parses the cmd `""`-doubled wrapper (#6152). @@ -85,7 +109,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('queues direct cmd syntax when the configured Windows shell is cmd.exe', () => { @@ -105,7 +129,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe('cd /d "C:\\Users\\alice\\repo" && claude "--resume" "session one"') + ).toBe('claude "--resume" "session one"') }) it('queues a POSIX command for the Git Bash Windows shell', () => { @@ -125,7 +149,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd 'C:\\Users\\alice\\repo' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('follows the live Windows shell for non-resumable agents in the fallback path', () => { @@ -144,9 +168,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe( - "Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; cursor-agent --resume 'session one'" - ) + ).toBe("cursor-agent --resume 'session one'") }) it('queues a PowerShell-valid local OMP resume by absolute transcript path', () => { @@ -166,9 +188,7 @@ describe('ai vault resume command runtime', () => { } }) - expect(command).toBe( - "Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; omp --resume 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl'" - ) + expect(command).toBe("omp --resume 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl'") expect(command).not.toContain('019f27cd-4268-7000-96e7-62f42a55c144') }) @@ -190,9 +210,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe( - 'cd /d "C:\\Users\\alice\\repo" && omp --resume "C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"' - ) + ).toBe('omp --resume "C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"') }) it('copies syntax that matches the configured cmd shell', () => { @@ -337,8 +355,8 @@ describe('ai vault resume command runtime', () => { } }) ).toEqual({ - command: - "cd '/home/alice/repo' && claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'", + command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'", + cwd: '/home/alice/repo', env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, launchConfig: { agentCommand: "claude '--dangerously-skip-permissions' '--effort' 'max'", @@ -367,7 +385,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd '/home/alice/repo' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('uses POSIX command wrapping for SSH-owned worktrees on Windows clients', () => { @@ -386,7 +404,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd '/home/alice/repo' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('uses POSIX command wrapping for folder workspaces with their own SSH target', () => { @@ -415,7 +433,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd '/home/alice/platform' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('uses POSIX command wrapping for WSL UNC folder workspaces on Windows clients', () => { @@ -443,7 +461,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd '/home/alice/platform' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('keeps WSL UNC worktrees on POSIX command wrapping without an explicit override', () => { @@ -470,7 +488,7 @@ describe('ai vault resume command runtime', () => { codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' } }) - ).toBe("cd '/home/alice/repo' && CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'") + ).toBe("CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'") }) it('converts WSL UNC OMP transcript paths before building Linux resume commands', () => { @@ -491,9 +509,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe( - "cd '/home/alice/repo' && omp --resume '/home/alice/.omp/agent/sessions/repo/sess.jsonl'" - ) + ).toBe("omp --resume '/home/alice/.omp/agent/sessions/repo/sess.jsonl'") }) it('deletes inherited Codex homes when resuming a real-home session', () => { @@ -511,7 +527,8 @@ describe('ai vault resume command runtime', () => { } }) ).toMatchObject({ - command: "Set-Location -LiteralPath '/home/alice/repo'; codex 'resume' 'session one'", + command: "codex 'resume' 'session one'", + cwd: '/home/alice/repo', envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] }) }) @@ -534,7 +551,8 @@ describe('ai vault resume command runtime', () => { } }) ).toMatchObject({ - command: "cd '/home/alice/repo' && codex 'resume' 'session one'", + command: "codex 'resume' 'session one'", + cwd: '/home/alice/repo', envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], providerSession: { key: 'session_id', id: 'session one' } }) @@ -558,7 +576,7 @@ describe('ai vault resume command runtime', () => { resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" } }) - ).toBe("cd '/home/alice/repo' && codex 'resume' 'session one'") + ).toBe("codex 'resume' 'session one'") }) it('copies remote real-home Codex commands with explicit environment cleanup', () => { @@ -603,7 +621,7 @@ describe('ai vault resume command runtime', () => { resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" } }) - ).toBe("cd '/home/alice/repo' && my-codex 'resume' 'session one'") + ).toBe("my-codex 'resume' 'session one'") }) it('rebuilds overridden remote commands with the recorded remote host platform', () => { @@ -629,9 +647,7 @@ describe('ai vault resume command runtime', () => { 'cmd /d /s /c "cd /d ""C:/Users/alice/repo"" && set ""CODEX_HOME=C:/Users/alice/.codex"" && codex resume ""session one"""' } }) - ).toBe( - "Set-Location -LiteralPath 'C:/Users/alice/repo'; $env:CODEX_HOME='C:/Users/alice/.codex'; my-codex 'resume' 'session one'" - ) + ).toBe("$env:CODEX_HOME='C:/Users/alice/.codex'; my-codex 'resume' 'session one'") }) it('ignores a stored resume command for local-host sessions', () => { @@ -651,6 +667,6 @@ describe('ai vault resume command runtime', () => { resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" } }) - ).toBe("cd '/home/alice/repo' && codex 'resume' 'session one'") + ).toBe("codex 'resume' 'session one'") }) }) diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index ed13d16e8a9..e8d05f61140 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -15,13 +15,8 @@ import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { parseWslUncPath } from '../../../shared/wsl-paths' -import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell' import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell' -import { - clearEnvCommand, - commandSeparator, - resolveStartupShell -} from '../../../shared/tui-agent-startup-shell' +import { clearEnvCommand, commandSeparator } from '../../../shared/tui-agent-startup-shell' import type { AppState } from '@/store/types' import type { AiVaultSessionDragPayload } from '@/lib/ai-vault-session-drag' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' @@ -29,7 +24,10 @@ import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' -import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { + getAiVaultResumeWorkspacePath, + resolveAiVaultResumeStartupShell +} from '@/lib/ai-vault-resume-shell' type AiVaultResumeCommandSession = Pick< AiVaultSession, @@ -41,6 +39,7 @@ type AiVaultResumeCommandSession = Pick< export type AiVaultResumeStartup = { command: string + cwd?: string env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig @@ -65,7 +64,7 @@ type AiVaultResumeWorktreeArgs = { } export function buildAiVaultResumeCopyCommandForWorktree(args: AiVaultResumeWorktreeArgs): string { - const command = buildAiVaultResumeForWorktree(args).command + const command = buildAiVaultResumeForWorktree(args, true).command if (args.session.agent !== 'codex' || args.session.codexHome !== null) { return command } @@ -80,7 +79,7 @@ export function buildAiVaultResumeCopyCommandForWorktree(args: AiVaultResumeWork export function buildAiVaultResumeStartupForWorktree( args: AiVaultResumeWorktreeArgs ): AiVaultResumeStartup { - return buildAiVaultResumeForWorktree(args) + return buildAiVaultResumeForWorktree(args, false) } /** @@ -120,7 +119,10 @@ export function buildAiVaultDropRepinStartup(args: { }) } -function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVaultResumeStartup { +function buildAiVaultResumeForWorktree( + args: AiVaultResumeWorktreeArgs, + embedCwd: boolean +): AiVaultResumeStartup { const providerSession = getAiVaultAgentProviderSession(args.session) if ( args.session.executionHostId && @@ -151,9 +153,11 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault const liveShell: AgentStartupShell | undefined = platform === 'win32' ? isLocalSession - ? resolveWindowsShellStartupFamily(args.state.settings?.terminalWindowsShell) + ? resolveAiVaultResumeShell(args) : 'powershell' : undefined + const cwd = embedCwd ? args.session.cwd : null + const startupCwd = !embedCwd && args.session.cwd ? { cwd: args.session.cwd } : {} if (providerSession && isResumableTuiAgent(args.session.agent)) { const startupPlan = buildAgentResumeStartupPlan({ agent: args.session.agent, @@ -181,7 +185,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault agent: args.session.agent, sessionId: args.session.sessionId, resumeFilePath, - cwd: args.session.cwd, + cwd, platform, commandOverride: startupPlan.launchConfig.agentCommand, codexHome, @@ -189,13 +193,14 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault }) : buildAiVaultResumeShellCommand({ resumeCommand: startupPlan.launchCommand, - cwd: args.session.cwd, + cwd, platform, codexHome, shell: liveShell }), ...(startupPlan.env ? { env: startupPlan.env } : {}), ...realHomeCodexResumeEnvDeletion(args.session), + ...startupCwd, launchConfig: startupPlan.launchConfig, providerSession } @@ -210,7 +215,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault // forward it too — otherwise a custom OMP_CODING_AGENT_DIR / WSL-store // session would resume by id against the default store and miss. resumeFilePath, - cwd: args.session.cwd, + cwd, platform, commandOverride: args.commandOverride, codexHome, @@ -218,6 +223,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault // quote for the live Windows shell like the startup-plan branch above. shell: liveShell }), + ...startupCwd, ...realHomeCodexResumeEnvDeletion(args.session) } } @@ -231,11 +237,12 @@ function resolveAiVaultResumeShell(args: AiVaultResumeWorktreeArgs): AgentStartu : getAiVaultResumePlatform(args.state, args.worktreeId) const isLocalSession = !args.session.executionHostId || args.session.executionHostId === LOCAL_EXECUTION_HOST_ID - const shell = - platform === 'win32' && isLocalSession - ? resolveWindowsShellStartupFamily(args.state.settings?.terminalWindowsShell) - : undefined - return resolveStartupShell(platform, shell) + return resolveAiVaultResumeStartupShell({ + state: args.state, + worktreeId: args.worktreeId, + platform, + isLocalSession + }) } export function getAiVaultAgentProviderSession( @@ -298,26 +305,3 @@ export function getAiVaultResumePlatform( const workspacePath = getAiVaultResumeWorkspacePath(state, targetWorktreeId) return workspacePath && parseWslUncPath(workspacePath) ? 'linux' : CLIENT_PLATFORM } - -function getAiVaultResumeWorkspacePath( - state: Pick, - worktreeId: string | null | undefined -): string | null { - if (!worktreeId) { - return null - } - const workspaceScope = parseWorkspaceKey(worktreeId) - if (workspaceScope?.type === 'folder') { - return ( - state.folderWorkspaces.find((workspace) => workspace.id === workspaceScope.folderWorkspaceId) - ?.folderPath ?? null - ) - } - const targetWorktreeId = - workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : worktreeId - return ( - Object.values(state.worktreesByRepo ?? {}) - .flat() - .find((candidate) => candidate.id === targetWorktreeId)?.path ?? null - ) -} diff --git a/src/renderer/src/lib/ai-vault-resume-shell.ts b/src/renderer/src/lib/ai-vault-resume-shell.ts new file mode 100644 index 00000000000..d1ab726484e --- /dev/null +++ b/src/renderer/src/lib/ai-vault-resume-shell.ts @@ -0,0 +1,72 @@ +import type { AppState } from '@/store/types' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../shared/local-windows-terminal-runtime' +import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell' +import { + resolveStartupShell, + type AgentStartupShell +} from '../../../shared/tui-agent-startup-shell' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { parseWslUncPath } from '../../../shared/wsl-paths' + +type AiVaultResumeShellState = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'folderWorkspaces' + | 'projects' + | 'repos' + | 'settings' + | 'worktreesByRepo' +> + +export function resolveAiVaultResumeStartupShell(args: { + state: AiVaultResumeShellState + worktreeId?: string | null + platform: NodeJS.Platform + isLocalSession: boolean +}): AgentStartupShell { + const projectRuntime = + args.platform === 'win32' && args.isLocalSession + ? getLocalProjectExecutionRuntimeContext(args.state, args.worktreeId, CLIENT_PLATFORM) + : undefined + const workspacePath = getAiVaultResumeWorkspacePath( + args.state, + args.worktreeId ?? args.state.activeWorktreeId + ) + const shellOverride = + args.platform === 'win32' && args.isLocalSession + ? resolveLocalWindowsTerminalShellOverrideForTab({ + explicitShellOverride: undefined, + defaultWindowsShell: args.state.settings?.terminalWindowsShell, + isWslWorktree: Boolean(workspacePath && parseWslUncPath(workspacePath)), + projectRuntime + }) + : undefined + const shell = shellOverride ? resolveWindowsShellStartupFamily(shellOverride) : undefined + return resolveStartupShell(args.platform, shell) +} + +export function getAiVaultResumeWorkspacePath( + state: Pick, + worktreeId: string | null | undefined +): string | null { + if (!worktreeId) { + return null + } + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + return ( + state.folderWorkspaces.find((workspace) => workspace.id === workspaceScope.folderWorkspaceId) + ?.folderPath ?? null + ) + } + const targetWorktreeId = + workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : worktreeId + return ( + Object.values(state.worktreesByRepo ?? {}) + .flat() + .find((candidate) => candidate.id === targetWorktreeId)?.path ?? null + ) +} diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index 9eaee5b643f..901bdd192b5 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -98,15 +98,20 @@ describe('launchAiVaultSessionInNewTab', () => { agent: 'claude', worktreeId: 'wt-1', command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'", + cwd: 'C:\\Users\\alice\\repo', env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, envToDelete: ['CODEX_HOME'], launchConfig: { agentCommand: "claude '--dangerously-skip-permissions' '--effort' 'max'", agentArgs: '--dangerously-skip-permissions --effort max', agentEnv: { ANTHROPIC_BASE_URL: 'https://claude.example.test' } - } + }, + providerSession: { key: 'session_id', id: 'session-1' } }) + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + startupCwd: 'C:\\Users\\alice\\repo' + }) expect(mockQueueTabStartupCommand).toHaveBeenCalledWith('tab-1', { command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'", env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, @@ -117,6 +122,7 @@ describe('launchAiVaultSessionInNewTab', () => { agentEnv: { ANTHROPIC_BASE_URL: 'https://claude.example.test' } }, launchAgent: 'claude', + resumeProviderSession: { key: 'session_id', id: 'session-1' }, telemetry: { agent_kind: 'claude', launch_source: 'sidebar', diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index 6e0b9a897c4..799b13ed5bd 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -22,6 +22,7 @@ export function launchAiVaultSessionInNewTab(args: { agent: AiVaultAgent worktreeId: string command: string + cwd?: string env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig @@ -40,6 +41,7 @@ export function launchAiVaultSessionInNewTab(args: { agentSessionKind: 'resume', launchAgent: args.agent, command: args.command, + ...(args.cwd ? { cwd: args.cwd } : {}), ...(args.env ? { env: args.env } : {}), ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), @@ -66,12 +68,15 @@ export function launchAiVaultSessionInNewTab(args: { targetGroupId } - const tab = store.createTab(args.worktreeId, targetGroupId) + const tab = args.cwd + ? store.createTab(args.worktreeId, targetGroupId, undefined, { startupCwd: args.cwd }) + : store.createTab(args.worktreeId, targetGroupId) store.queueTabStartupCommand(tab.id, { command: args.command, ...(args.env ? { env: args.env } : {}), ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), ...(args.launchConfig ? { launchConfig: args.launchConfig, launchAgent: args.agent } : {}), + ...(args.providerSession ? { resumeProviderSession: args.providerSession } : {}), telemetry: { agent_kind: tuiAgentToAgentKind(args.agent), launch_source: 'sidebar', From 6e63bbbb522987f08a83fba676031067faa01d91 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:36 -0700 Subject: [PATCH 022/271] fix(computer-use): prevent repeated screen recording prompts (#13427) --- .../Sources/OrcaComputerUseMacOS/main.swift | 40 +--------- .../PermissionTrustSettling.swift | 29 ------- .../ScreenCaptureProbeWindowSelection.swift | 22 ------ ...apturePermissionPreflightSafetyTests.swift | 31 ++++++++ ...ScreenCapturePermissionSettlingTests.swift | 79 ------------------- ...reenCaptureProbeWindowSelectionTests.swift | 44 ----------- 6 files changed, 32 insertions(+), 213 deletions(-) delete mode 100644 native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionPreflightSafetyTests.swift delete mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift delete mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 61ee277156d..0c56041ee46 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -1044,11 +1044,7 @@ private func screenCaptureTrusted() -> Bool { } private func screenCaptureTrustedSettled() -> Bool { - PermissionTrustSettling.settleWithFallback( - finalTimeoutMs: 500, - probe: screenCaptureTrusted, - fallbackProbe: screenCaptureTrustedByCaptureProbe - ) + PermissionTrustSettling.settle(timeoutMs: 2_000, probe: screenCaptureTrusted).settled } private func permissionStatusSnapshotSettled() -> PermissionStatusSnapshot { @@ -1058,40 +1054,6 @@ private func permissionStatusSnapshotSettled() -> PermissionStatusSnapshot { ) } -private func screenCaptureTrustedByCaptureProbe() -> Bool { - guard let infos = CGWindowListCopyWindowInfo( - [.optionOnScreenOnly], - kCGNullWindowID - ) as? [[String: Any]] else { - return false - } - let windows = infos.compactMap { info -> ScreenCaptureProbeWindow? in - guard let layer = info[kCGWindowLayer as String] as? Int, - let ownerPid = info[kCGWindowOwnerPID as String] as? NSNumber, - let number = info[kCGWindowNumber as String] as? NSNumber - else { - return nil - } - return ScreenCaptureProbeWindow( - layer: layer, - ownerPid: ownerPid.int32Value, - windowId: number.uint32Value - ) - } - guard let windowId = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( - ownPid: ProcessInfo.processInfo.processIdentifier, - windows: windows - ) else { - return false - } - return CGWindowListCreateImage( - .null, - [.optionIncludingWindow], - CGWindowID(windowId), - [.boundsIgnoreFraming] - ) != nil -} - private func requestScreenCaptureAccess() -> Bool { CGRequestScreenCaptureAccess() } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift index 80612664454..0f7d0bd8cf6 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift @@ -41,33 +41,4 @@ public enum PermissionTrustSettling { waitedMs += interval } } - - public static func settleWithFallback( - initialTimeoutMs: Int = defaultTimeoutMs, - finalTimeoutMs: Int, - intervalMs: Int = defaultIntervalMs, - sleepMs: (Int) -> Void = { interval in - Thread.sleep(forTimeInterval: TimeInterval(interval) / 1_000) - }, - probe: () -> Bool, - fallbackProbe: () -> Bool - ) -> Bool { - if settle( - timeoutMs: initialTimeoutMs, - intervalMs: intervalMs, - sleepMs: sleepMs, - probe: probe - ).settled { - return true - } - if fallbackProbe() { - return true - } - return settle( - timeoutMs: finalTimeoutMs, - intervalMs: intervalMs, - sleepMs: sleepMs, - probe: probe - ).settled - } } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift deleted file mode 100644 index 36b4346a8bc..00000000000 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift +++ /dev/null @@ -1,22 +0,0 @@ -public struct ScreenCaptureProbeWindow: Equatable { - public let layer: Int - public let ownerPid: Int32 - public let windowId: UInt32 - - public init(layer: Int, ownerPid: Int32, windowId: UInt32) { - self.layer = layer - self.ownerPid = ownerPid - self.windowId = windowId - } -} - -public enum ScreenCaptureProbeWindowSelection { - public static func firstCrossProcessNormalWindow( - ownPid: Int32, - windows: [ScreenCaptureProbeWindow] - ) -> UInt32? { - windows.first { window in - window.layer == 0 && window.ownerPid != ownPid - }?.windowId - } -} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionPreflightSafetyTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionPreflightSafetyTests.swift new file mode 100644 index 00000000000..95deec57a04 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionPreflightSafetyTests.swift @@ -0,0 +1,31 @@ +import XCTest + +final class ScreenCapturePermissionPreflightSafetyTests: XCTestCase { + func testPermissionPreflightCannotRequestOrCaptureTheScreen() throws { + let source = try computerUseSource() + let start = try XCTUnwrap(source.range(of: "private func screenCaptureTrusted()")) + let end = try XCTUnwrap( + source.range(of: "private func requestScreenCaptureAccess()", range: start.upperBound.. String { + let testFile = URL(fileURLWithPath: #filePath) + let packageRoot = testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let mainPath = packageRoot + .appendingPathComponent("Sources") + .appendingPathComponent("OrcaComputerUseMacOS") + .appendingPathComponent("main.swift") + return try String(contentsOf: mainPath, encoding: .utf8) + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift deleted file mode 100644 index d53991d36d0..00000000000 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Testing -@testable import OrcaComputerUseMacOSCore - -@Suite("Screen Capture permission settling") -struct ScreenCapturePermissionSettlingTests { - @Test("initial preflight success skips fallback") - func initialSuccess() { - var fallbackCalls = 0 - let trusted = PermissionTrustSettling.settleWithFallback( - finalTimeoutMs: 500, - sleepMs: { _ in }, - probe: { true }, - fallbackProbe: { - fallbackCalls += 1 - return true - } - ) - - #expect(trusted) - #expect(fallbackCalls == 0) - } - - @Test("capture fallback can establish trust") - func fallbackSuccess() { - var fallbackCalls = 0 - let trusted = PermissionTrustSettling.settleWithFallback( - initialTimeoutMs: 200, - finalTimeoutMs: 200, - intervalMs: 100, - sleepMs: { _ in }, - probe: { false }, - fallbackProbe: { - fallbackCalls += 1 - return true - } - ) - - #expect(trusted) - #expect(fallbackCalls == 1) - } - - @Test("preflight is retried after fallback failure") - func finalPreflightSuccess() { - var probeCalls = 0 - let trusted = PermissionTrustSettling.settleWithFallback( - initialTimeoutMs: 100, - finalTimeoutMs: 200, - intervalMs: 100, - sleepMs: { _ in }, - probe: { - probeCalls += 1 - return probeCalls == 4 - }, - fallbackProbe: { false } - ) - - #expect(trusted) - #expect(probeCalls == 4) - } - - @Test("persistent denial remains denied") - func persistentDenial() { - var fallbackCalls = 0 - let trusted = PermissionTrustSettling.settleWithFallback( - initialTimeoutMs: 100, - finalTimeoutMs: 100, - intervalMs: 100, - sleepMs: { _ in }, - probe: { false }, - fallbackProbe: { - fallbackCalls += 1 - return false - } - ) - - #expect(!trusted) - #expect(fallbackCalls == 1) - } -} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift deleted file mode 100644 index 96afd6fbccb..00000000000 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Testing -@testable import OrcaComputerUseMacOSCore - -@Suite("Screen capture probe window selection") -struct ScreenCaptureProbeWindowSelectionTests { - @Test("own process windows cannot prove cross-app capture permission") - func excludesOwnWindows() { - let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( - ownPid: 42, - windows: [ - .init(layer: 0, ownerPid: 42, windowId: 1), - .init(layer: 0, ownerPid: 84, windowId: 2) - ] - ) - - #expect(selected == 2) - } - - @Test("non-normal windows are skipped") - func excludesNonNormalWindows() { - let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( - ownPid: 42, - windows: [ - .init(layer: 1, ownerPid: 84, windowId: 1), - .init(layer: 0, ownerPid: 84, windowId: 2) - ] - ) - - #expect(selected == 2) - } - - @Test("no cross-process normal window returns nil") - func noCandidate() { - let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( - ownPid: 42, - windows: [ - .init(layer: 0, ownerPid: 42, windowId: 1), - .init(layer: 1, ownerPid: 84, windowId: 2) - ] - ) - - #expect(selected == nil) - } -} From 9ccec550c35ca44ad106dd3ca699ad952c37a622 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:25:15 -0700 Subject: [PATCH 023/271] Add emoji picker to workspace editing and search (#13429) * Add emoji picker to workspace editing and search * fix(sidebar): scope sticky headers by host * fix: address emoji picker review findings --- .../WorktreeJumpPalette.recent-tabs.test.tsx | 2 + .../components/WorktreeJumpPalette.test.tsx | 48 ++++-- .../src/components/WorktreeJumpPalette.tsx | 25 ++- .../new-workspace/SmartWorkspaceNameField.tsx | 2 +- .../sidebar/WorktreeDisplayNameField.tsx | 79 +++++++++ .../sidebar/WorktreeMetaDialog.test.tsx | 11 ++ .../components/sidebar/WorktreeMetaDialog.tsx | 33 ++-- ...eeTitleInlineRename.begin-editing.test.tsx | 22 +++ .../sidebar/WorktreeTitleInlineRename.tsx | 126 +++++++++----- .../worktree-list-virtual-rows.test.ts | 42 ++++- .../sidebar/worktree-list-virtual-rows.ts | 2 +- .../WorkspaceEmojiSuggestionPopover.tsx | 18 +- .../useWorkspaceEmojiShortcodeInput.test.tsx | 122 ++++++++++++++ .../useWorkspaceEmojiShortcodeInput.ts | 158 ++++++++++++++++++ ...jump-palette-interleaved-sections.test.tsx | 2 + tests/e2e/workspace-emoji-picker.spec.ts | 88 ++++++++++ 16 files changed, 688 insertions(+), 92 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx rename src/renderer/src/components/{new-workspace => workspace-emoji}/WorkspaceEmojiSuggestionPopover.tsx (82%) create mode 100644 src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx create mode 100644 src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.ts create mode 100644 tests/e2e/workspace-emoji-picker.spec.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx index 68de1317688..e263a0db926 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx @@ -69,6 +69,8 @@ vi.mock('@/lib/workspace-tab-palette-activation', () => ({ vi.mock('@/components/ui/command', async () => { const React = await import('react') return { + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, // Why the commandProps passthrough: cmdk resolves Enter against its `value`, so the controlled // value is the only honest stand-in for "what would Enter activate" without mounting real cmdk. CommandDialog: ({ diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index cd1149f7224..47215a8c941 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -2,6 +2,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { fireEvent } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as ReactI18Next from 'react-i18next' import { useAppStore } from '@/store' @@ -74,25 +75,40 @@ vi.mock('@/components/ui/command', async () => { ) : null }, - CommandInput: ({ - value, - onValueChange, - placeholder - }: { - value?: string - onValueChange?: (next: string) => void - placeholder?: string - }) => { + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandInput: React.forwardRef(function CommandInput( + { + value, + onValueChange, + placeholder, + onClick, + onSelect, + onKeyDown + }: { + value?: string + onValueChange?: (next: string) => void + placeholder?: string + onClick?: React.MouseEventHandler + onSelect?: React.ReactEventHandler + onKeyDown?: React.KeyboardEventHandler + }, + ref: React.ForwardedRef + ) { setCommandQuery = onValueChange ?? null return ( onValueChange?.(event.currentTarget.value)} + onClick={onClick} + onSelect={onSelect} + onKeyDown={onKeyDown} /> ) - }, + }), CommandList: React.forwardRef(function CommandList( { children }: { children: React.ReactNode }, ref: React.ForwardedRef @@ -337,4 +353,16 @@ describe('WorktreeJumpPalette', () => { expect(testContainer.textContent).toContain('Feature workspace') }) + + it('replaces a completed emoji shortcode in the search query', async () => { + await renderPalette({ worktreesByRepo: { 'repo-1': [] } }) + const input = testContainer.querySelector('[data-command-input="true"]') + expect(input).not.toBeNull() + + await act(async () => { + fireEvent.change(input!, { target: { value: ':wink:', selectionStart: 6 } }) + }) + + expect(input?.value).toBe('😉') + }) }) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 89068eb489b..a8adbec77a9 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -169,6 +169,8 @@ import { resolvePaletteFocusRestoreTarget } from '@/components/cmd-j/palette-foc import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' import { buildPluginQuickActions } from '@/components/cmd-j/plugin-quick-actions' +import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover' +import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput' import { usePluginCommands } from '@/store/plugin-panels' import { getComposerEligibleRepos, @@ -1893,6 +1895,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { setSelectedItemId('') listRef.current?.scrollTo(0, 0) }, []) + const emojiInput = useWorkspaceEmojiShortcodeInput({ + inputRef, + onValueChange: handleQueryChange, + value: query + }) const cancelFallbackFocusFrames = useCallback((): void => { if (fallbackFocusOuterFrameRef.current !== null) { @@ -2455,7 +2462,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'Search chats, terminals, worktrees, settings, and actions...' )} value={query} - onValueChange={handleQueryChange} + onValueChange={emojiInput.handleValueChange} + onClick={(event) => emojiInput.syncCursor(event.currentTarget)} + onSelect={(event) => emojiInput.syncCursor(event.currentTarget)} + onKeyDown={(event) => emojiInput.handleKeyDown(event)} wrapperClassName="mx-3 mt-3 rounded-lg border border-border/55 bg-muted/28 px-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]" iconClassName="mr-2.5 h-4 w-4 text-muted-foreground/60" className="h-12 text-[14px] placeholder:text-muted-foreground/75" @@ -2471,6 +2481,19 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } /> + !open && emojiInput.close()} + portalContainer={dialogElement} + side="bottom" + contentClassName="w-80" + /> ['repos'][number] const EMPTY_REPO_SEARCH_REPOS: readonly RepoOption[] = [] diff --git a/src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx b/src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx new file mode 100644 index 00000000000..715c9976e17 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx @@ -0,0 +1,79 @@ +import { useId, type RefObject } from 'react' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover' +import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput' +import { translate } from '@/i18n/i18n' + +type WorktreeDisplayNameFieldProps = { + disabled: boolean + inputRef: RefObject + onEnter: () => void | Promise + onValueChange: (value: string) => void + portalContainer: HTMLElement | null + value: string +} + +export function WorktreeDisplayNameField({ + disabled, + inputRef, + onEnter, + onValueChange, + portalContainer, + value +}: WorktreeDisplayNameFieldProps): React.JSX.Element { + const inputId = useId() + const emojiInput = useWorkspaceEmojiShortcodeInput({ + disabled, + inputRef, + onValueChange, + value + }) + + return ( +
+ + + emojiInput.handleValueChange(event.target.value, event.target.selectionStart) + } + onSelect={(event) => emojiInput.syncCursor(event.currentTarget)} + onKeyDown={(event) => { + if (emojiInput.handleKeyDown(event) || event.key !== 'Enter') { + return + } + event.preventDefault() + void onEnter() + }} + placeholder={translate( + 'auto.components.sidebar.WorktreeMetaDialog.7f21e0464f', + 'Custom display name...' + )} + className="h-8 text-xs" + /> + !open && emojiInput.close()} + portalContainer={portalContainer} + side="bottom" + /> +

+ {translate( + 'auto.components.sidebar.WorktreeMetaDialog.459ad7f650', + 'Only changes the name shown in the sidebar — the folder on disk stays the same. Leave blank to use the branch or folder name.' + )} +

+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx index b45585a4628..66de27601e5 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx @@ -225,6 +225,17 @@ describe('WorktreeMetaDialog issue link row', () => { expect(issueInput().value).toBe('42') }) + it('replaces a completed emoji shortcode in the display name', () => { + openDialog() + const displayNameInput = screen.getByRole('textbox', { name: 'Display Name' }) + + fireEvent.change(displayNameInput, { + target: { value: 'Feature :wink:', selectionStart: 14 } + }) + + expect((displayNameInput as HTMLInputElement).value).toBe('Feature 😉') + }) + it('seeds the chip and value from a Linear link', () => { openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 4445520fd72..0482a644221 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -30,6 +30,7 @@ import { parseIssueLinkInput, type IssueLinkProvider } from '../../../../shared/issue-link-input' +import { WorktreeDisplayNameField } from './WorktreeDisplayNameField' function resizeCommentTextarea(textarea: HTMLTextAreaElement): void { textarea.style.height = 'auto' @@ -93,6 +94,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT) + const [dialogElement, setDialogElement] = useState(null) const { canOpenIssue, openingIssue, openIssueFailed, handleOpenIssue, resetOpeningIssue } = useWorktreeIssueLink({ worktreeId, @@ -282,6 +284,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { return ( { e.preventDefault() @@ -312,28 +315,14 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
-
- - setDisplayNameInput(e.target.value)} - onKeyDown={handleIssueKeyDown} - placeholder={translate( - 'auto.components.sidebar.WorktreeMetaDialog.7f21e0464f', - 'Custom display name...' - )} - className="h-8 text-xs" - /> -

- {translate( - 'auto.components.sidebar.WorktreeMetaDialog.459ad7f650', - 'Only changes the name shown in the sidebar — the folder on disk stays the same. Leave blank to use the branch or folder name.' - )} -

-
+ ({ } })) +vi.mock('@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover', () => ({ + WorkspaceEmojiSuggestionPopover: () => null +})) + +vi.mock('@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput', () => ({ + useWorkspaceEmojiShortcodeInput: ({ + onValueChange + }: { + onValueChange: (value: string) => void + }) => ({ + close: vi.fn(), + commandValue: '', + handleKeyDown: () => false, + handleValueChange: (value: string) => onValueChange(value), + onCommandValueChange: vi.fn(), + open: false, + selectSuggestion: vi.fn(), + suggestions: [], + syncCursor: vi.fn() + }) +})) + type ReactElementLike = { type: unknown props: Record diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx index 679460541a0..ec670c3c506 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx @@ -3,6 +3,8 @@ import { LoaderCircle } from 'lucide-react' import { toast } from 'sonner' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover' +import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput' import { cn } from '@/lib/utils' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' @@ -68,10 +70,17 @@ export function WorktreeTitleInlineRename({ const titleElementRef = useRef(null) const titleResizeObserverRef = useRef(null) const removeTitleResizeListenerRef = useRef<(() => void) | null>(null) + const inputElementRef = useRef(null) const [editing, setEditing] = useState(false) const [value, setValue] = useState(displayName) const [saving, setSaving] = useState(false) const [titleTruncated, setTitleTruncated] = useState(false) + const emojiInput = useWorkspaceEmojiShortcodeInput({ + disabled: saving, + inputRef: inputElementRef, + onValueChange: setValue, + value + }) const measureTitleTruncated = useCallback((element: HTMLSpanElement | null) => { const nextTruncated = element ? isWorktreeTitleTruncated(element) : false @@ -141,6 +150,7 @@ export function WorktreeTitleInlineRename({ ) const handleInputRef = useCallback((input: HTMLInputElement | null) => { + inputElementRef.current = input if (!input) { return } @@ -182,9 +192,10 @@ export function WorktreeTitleInlineRename({ ) const cancelRename = useCallback(() => { + emojiInput.close() setValue(displayName) setEditingMode(false) - }, [displayName, setEditingMode]) + }, [displayName, emojiInput, setEditingMode]) const commitRename = useCallback(async () => { if (savingRef.current) { @@ -226,6 +237,9 @@ export function WorktreeTitleInlineRename({ const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { event.stopPropagation() + if (emojiInput.handleKeyDown(event)) { + return + } // Why: an Enter that only confirms a CJK IME candidate must not commit the // rename; wait for a non-composition Enter. if (isImeCompositionKeyDown(event)) { @@ -239,61 +253,81 @@ export function WorktreeTitleInlineRename({ cancelRename() } }, - [cancelRename, commitRename] + [cancelRename, commitRename, emojiInput] ) if (editing) { return ( - + <> - setValue(event.target.value)} - onBlur={() => void commitRename()} - onClick={stopCardEvent} - onDoubleClick={stopCardEvent} - onPointerDown={stopCardEvent} - onKeyDown={handleKeyDown} + key={`editing:${titleElementKey}`} + ref={handleRootRef} className={cn( - 'col-start-1 row-start-1 min-w-0 select-text truncate text-foreground outline-none', - editingInputClassName, - saving && savingInputClassName, - inputClassName + 'relative grid min-w-0 truncate leading-tight text-foreground', + showUnreadEmphasis ? 'font-semibold' : 'font-normal', + className, + editingClassName )} - /> - {saving ? ( - + + + emojiInput.handleValueChange(event.target.value, event.target.selectionStart) + } + onSelect={(event) => emojiInput.syncCursor(event.currentTarget)} + onBlur={() => void commitRename()} + onClick={stopCardEvent} + onDoubleClick={stopCardEvent} + onPointerDown={stopCardEvent} + onKeyDown={handleKeyDown} className={cn( - 'pointer-events-none absolute top-1/2 size-3 -translate-y-1/2 animate-spin text-muted-foreground', - savingSpinnerClassName + 'col-start-1 row-start-1 min-w-0 select-text truncate text-foreground outline-none', + editingInputClassName, + saving && savingInputClassName, + inputClassName )} /> - ) : null} - + {saving ? ( + + ) : null} + + !open && emojiInput.close()} + side="right" + contentClassName="w-56" + /> + ) } diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts index 04d2857ed64..67b6147765e 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { VirtualItem } from '@tanstack/react-virtual' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { HOST_STICKY_PINNED_HEIGHT, buildLineageRowRekeyMap, @@ -11,11 +12,11 @@ import { type RenderRow } from './worktree-list-virtual-rows' -function hostRow(hostId: string): RenderRow { +function hostRow(hostId: ExecutionHostId): RenderRow { return { type: 'host-header', key: `host:${hostId}`, - hostId: hostId as never, + hostId, kind: 'ssh', label: hostId, detail: 'SSH', @@ -25,8 +26,15 @@ function hostRow(hostId: string): RenderRow { } } -function groupRow(key: string): RenderRow { - return { type: 'header', key, label: key, count: 1, tone: 'text-foreground' } +function groupRow(key: string, hostId?: ExecutionHostId): RenderRow { + return { + type: 'header', + key, + label: key, + count: 1, + tone: 'text-foreground', + ...(hostId ? { hostId } : {}) + } } function itemStub(id: string): RenderRow { @@ -39,11 +47,11 @@ function virtualItem(index: number, start: number): VirtualItem { // rows: [host-a, group-a1, item, item, host-b, group-b1, item] const rows: RenderRow[] = [ - hostRow('a'), + hostRow('ssh:a'), groupRow('a1'), itemStub('wt-1'), itemStub('wt-2'), - hostRow('b'), + hostRow('ssh:b'), groupRow('b1'), itemStub('wt-3') ] @@ -51,6 +59,23 @@ const stickyHeaderIndexes = getStickyHeaderIndexes(rows) // Geometry: each row 100px tall for easy math. const virtualItems = rows.map((_, index) => virtualItem(index, index * 100)) +describe('getRenderRowKey', () => { + it('scopes repeated group headers to their host section', () => { + expect(getRenderRowKey(groupRow('workspace-status:in-progress', 'local'))).toBe( + 'hdr:local:workspace-status:in-progress' + ) + expect(getRenderRowKey(groupRow('workspace-status:in-progress', 'ssh:builder'))).toBe( + 'hdr:ssh:builder:workspace-status:in-progress' + ) + }) + + it('preserves unsectioned group header keys', () => { + expect(getRenderRowKey(groupRow('workspace-status:in-progress'))).toBe( + 'hdr:workspace-status:in-progress' + ) + }) +}) + describe('getActiveStickyIndexesForScroll', () => { it('pins the host and its inner group while scrolled inside a section', () => { expect( @@ -149,7 +174,7 @@ describe('getActiveStickyIndexesForScroll', () => { it('keeps the previous mounted Project sticky when the next group is unmounted', () => { const multiGroupRows: RenderRow[] = [ - hostRow('a'), + hostRow('ssh:a'), groupRow('a1'), itemStub('wt-1'), groupRow('a2'), @@ -263,7 +288,8 @@ describe('buildLineageRowRekeyMap', () => { it('contributes nothing for non-lineage row types', () => { expect( - buildLineageRowRekeyMap([hostRow('a'), groupRow('a1'), hostRow('b'), groupRow('b1')]).size + buildLineageRowRekeyMap([hostRow('ssh:a'), groupRow('a1'), hostRow('ssh:b'), groupRow('b1')]) + .size ).toBe(0) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index 1ec2e07a8a1..69b8c71c010 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -20,7 +20,7 @@ export function getRenderRowKey(row: RenderRow): string { return `host:${row.hostId}` } if (row.type === 'header') { - return `hdr:${row.key}` + return row.hostId ? `hdr:${row.hostId}:${row.key}` : `hdr:${row.key}` } if (row.type === 'lineage-group') { return `lineage-group:${row.key}` diff --git a/src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx b/src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.tsx similarity index 82% rename from src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx rename to src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.tsx index 209fd72311c..7497f080a75 100644 --- a/src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx +++ b/src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.tsx @@ -1,27 +1,34 @@ -import type { RefObject } from 'react' +import type { ComponentProps, RefObject } from 'react' import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { cn } from '@/lib/utils' import type { WorkspaceEmojiSuggestion } from '@/lib/workspace-emoji-shortcodes' type WorkspaceEmojiSuggestionPopoverProps = { anchorRef: RefObject commandValue: string + contentClassName?: string heading: string onCommandValueChange: (value: string) => void onOpenChange: (open: boolean) => void onSelect: (suggestion: WorkspaceEmojiSuggestion) => void open: boolean + portalContainer?: HTMLElement | null + side?: ComponentProps['side'] suggestions: readonly WorkspaceEmojiSuggestion[] } export function WorkspaceEmojiSuggestionPopover({ anchorRef, commandValue, + contentClassName, heading, onCommandValueChange, onOpenChange, onSelect, open, + portalContainer, + side = 'top', suggestions }: WorkspaceEmojiSuggestionPopoverProps): React.JSX.Element { return ( @@ -30,11 +37,16 @@ export function WorkspaceEmojiSuggestionPopover({ event.preventDefault()} + onPointerDown={(event) => event.preventDefault()} onPointerDownOutside={(event) => { if (anchorRef.current?.contains(event.target as Node)) { event.preventDefault() diff --git a/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx b/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx new file mode 100644 index 00000000000..d750bd3df9a --- /dev/null +++ b/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment happy-dom + +import { useRef, useState } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useWorkspaceEmojiShortcodeInput } from './useWorkspaceEmojiShortcodeInput' + +function EmojiInputHarness({ + initialValue, + onUnhandledKeyDown +}: { + initialValue: string + onUnhandledKeyDown?: (event: React.KeyboardEvent) => void +}): React.JSX.Element { + const [value, setValue] = useState(initialValue) + const inputRef = useRef(null) + const emojiInput = useWorkspaceEmojiShortcodeInput({ + inputRef, + onValueChange: setValue, + value + }) + + return ( +
+ + emojiInput.handleValueChange( + event.currentTarget.value, + event.currentTarget.selectionStart + ) + } + onSelect={(event) => emojiInput.syncCursor(event.currentTarget)} + onKeyDown={(event) => { + if (!emojiInput.handleKeyDown(event)) { + onUnhandledKeyDown?.(event) + } + }} + /> + {emojiInput.suggestions.map((suggestion) => ( + + ))} +
+ ) +} + +describe('useWorkspaceEmojiShortcodeInput', () => { + afterEach(cleanup) + + it('replaces a completed shortcode without discarding trailing text', () => { + render() + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + fireEvent.change(input, { + target: { value: 'Launch :wink: experiment', selectionStart: 13 } + }) + + expect((input as HTMLInputElement).value).toBe('Launch 😉 experiment') + }) + + it('opens suggestions and accepts the highlighted emoji with Enter', () => { + render() + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + fireEvent.change(input, { target: { value: 'Launch :wink', selectionStart: 12 } }) + + expect(screen.getByText(':wink:')).toBeTruthy() + expect(input.parentElement?.dataset.emojiMenuOpen).toBe('true') + fireEvent.keyDown(input, { key: 'Enter' }) + expect((input as HTMLInputElement).value).toBe('Launch 😉 ') + }) + + it('dismisses suggestions with Escape without clearing the query', () => { + render() + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + fireEvent.change(input, { target: { value: 'Launch :wink', selectionStart: 12 } }) + fireEvent.keyDown(input, { key: 'Escape' }) + + expect((input as HTMLInputElement).value).toBe('Launch :wink') + expect(input.parentElement?.dataset.emojiMenuOpen).toBe('false') + }) + + it('leaves composing arrow navigation to the IME', () => { + const onUnhandledKeyDown = vi.fn() + render( + + ) + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + ;(input as HTMLInputElement).setSelectionRange(12, 12) + fireEvent.select(input) + const handled = fireEvent.keyDown(input, { key: 'ArrowDown', isComposing: true }) + + expect(handled).toBe(true) + expect(onUnhandledKeyDown).not.toHaveBeenCalled() + }) + + it('does not accept or submit on a composing Enter', () => { + const onUnhandledKeyDown = vi.fn() + render( + + ) + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + ;(input as HTMLInputElement).setSelectionRange(12, 12) + fireEvent.select(input) + const handled = fireEvent.keyDown(input, { key: 'Enter', isComposing: true }) + + expect(handled).toBe(true) + expect(onUnhandledKeyDown).not.toHaveBeenCalled() + expect((input as HTMLInputElement).value).toBe('Launch :wink') + }) +}) diff --git a/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.ts b/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.ts new file mode 100644 index 00000000000..567e3ed8bfb --- /dev/null +++ b/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.ts @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' +import { + applyWorkspaceEmojiSuggestion, + getActiveWorkspaceEmojiShortcode, + replaceCompletedWorkspaceEmojiShortcode, + searchWorkspaceEmojiShortcodes, + type WorkspaceEmojiReplacement, + type WorkspaceEmojiSuggestion +} from '@/lib/workspace-emoji-shortcodes' + +type WorkspaceEmojiShortcodeInputOptions = { + disabled?: boolean + inputRef: RefObject + onValueChange: (value: string) => void + value: string +} + +export function useWorkspaceEmojiShortcodeInput({ + disabled = false, + inputRef, + onValueChange, + value +}: WorkspaceEmojiShortcodeInputOptions) { + const [cursor, setCursor] = useState(null) + const [commandValue, setCommandValue] = useState('') + const focusFrameRef = useRef(null) + const activeShortcode = useMemo( + () => getActiveWorkspaceEmojiShortcode(value, cursor), + [cursor, value] + ) + const suggestions = useMemo( + () => (activeShortcode ? searchWorkspaceEmojiShortcodes(activeShortcode.query) : []), + [activeShortcode] + ) + const open = !disabled && activeShortcode !== null && suggestions.length > 0 + const resolvedCommandValue = suggestions.some( + (suggestion) => `emoji:${suggestion.shortcode}` === commandValue + ) + ? commandValue + : suggestions[0] + ? `emoji:${suggestions[0].shortcode}` + : '' + const selectedSuggestion = + suggestions.find((suggestion) => `emoji:${suggestion.shortcode}` === resolvedCommandValue) ?? + null + + const cancelFocusFrame = useCallback(() => { + if (focusFrameRef.current !== null) { + cancelAnimationFrame(focusFrameRef.current) + focusFrameRef.current = null + } + }, []) + + useEffect(() => cancelFocusFrame, [cancelFocusFrame]) + + const applyReplacement = useCallback( + (replacement: WorkspaceEmojiReplacement) => { + onValueChange(replacement.value) + setCursor(null) + cancelFocusFrame() + focusFrameRef.current = requestAnimationFrame(() => { + focusFrameRef.current = null + inputRef.current?.focus({ preventScroll: true }) + inputRef.current?.setSelectionRange(replacement.cursor, replacement.cursor) + }) + }, + [cancelFocusFrame, inputRef, onValueChange] + ) + + const handleValueChange = useCallback( + ( + nextValue: string, + nextCursor: number | null = inputRef.current?.selectionStart ?? nextValue.length + ) => { + const completedEmoji = replaceCompletedWorkspaceEmojiShortcode(nextValue, nextCursor) + if (completedEmoji) { + applyReplacement(completedEmoji) + return + } + onValueChange(nextValue) + setCursor(nextCursor) + }, + [applyReplacement, inputRef, onValueChange] + ) + + const syncCursor = useCallback( + (input = inputRef.current) => setCursor(input?.selectionStart ?? null), + [inputRef] + ) + + const close = useCallback(() => setCursor(null), []) + + const selectSuggestion = useCallback( + (suggestion: WorkspaceEmojiSuggestion) => { + if (!activeShortcode) { + return + } + applyReplacement(applyWorkspaceEmojiSuggestion(value, activeShortcode, suggestion)) + }, + [activeShortcode, applyReplacement, value] + ) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): boolean => { + if (!open) { + return false + } + if (isImeCompositionKeyDown(event)) { + event.stopPropagation() + return true + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + event.stopPropagation() + const selectedIndex = suggestions.findIndex( + (suggestion) => `emoji:${suggestion.shortcode}` === resolvedCommandValue + ) + const direction = event.key === 'ArrowDown' ? 1 : -1 + const nextIndex = (selectedIndex + direction + suggestions.length) % suggestions.length + setCommandValue(`emoji:${suggestions[nextIndex].shortcode}`) + return true + } + const acceptsSuggestion = + (event.key === 'Enter' && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey) || + (event.key === 'Tab' && !event.shiftKey) + if (acceptsSuggestion && selectedSuggestion) { + event.preventDefault() + event.stopPropagation() + selectSuggestion(selectedSuggestion) + return true + } + if (event.key === 'Escape') { + event.stopPropagation() + close() + return true + } + return false + }, + [close, open, resolvedCommandValue, selectSuggestion, selectedSuggestion, suggestions] + ) + + return { + close, + commandValue: resolvedCommandValue, + handleKeyDown, + handleValueChange, + onCommandValueChange: setCommandValue, + open, + selectSuggestion, + suggestions, + syncCursor + } +} diff --git a/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx b/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx index 95b1db5e5ab..aad685c5666 100644 --- a/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx +++ b/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx @@ -52,6 +52,8 @@ vi.mock('@/components/cmd-j/palette-host-badge', () => ({ vi.mock('@/components/ui/command', async () => { const React = await import('react') return { + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, CommandDialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => open ?
{children}
: null, CommandInput: ({ diff --git a/tests/e2e/workspace-emoji-picker.spec.ts b/tests/e2e/workspace-emoji-picker.spec.ts new file mode 100644 index 00000000000..f16499a22bc --- /dev/null +++ b/tests/e2e/workspace-emoji-picker.spec.ts @@ -0,0 +1,88 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +async function captureProof(page: Page, testInfo: TestInfo, name: string): Promise { + if (process.env.ORCA_E2E_RECORD_VIDEO === '1') { + return + } + const screenshotPath = testInfo.outputPath(name) + await page.screenshot({ path: screenshotPath }) + await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' }) +} + +test.describe('Workspace emoji picker', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await orcaPage.waitForTimeout(750) + }) + + test('inserts emoji in sidebar rename, worktree details, and Cmd+J', async ({ + orcaPage + }, testInfo) => { + const title = orcaPage.locator('[data-worktree-title-inline-rename=""]').first() + await expect(title).toBeVisible() + await title.dblclick() + + const inlineInput = orcaPage.locator('[data-worktree-title-rename-input="true"]') + await expect(inlineInput).toBeVisible() + await inlineInput.fill('Sidebar proof') + await captureProof(orcaPage, testInfo, 'sidebar-rename-before.png') + await inlineInput.pressSequentially(' :wink', { delay: 60 }) + const inlineSuggestions = orcaPage.locator('[data-workspace-emoji-suggestions="true"]') + await expect(inlineSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible() + await captureProof(orcaPage, testInfo, 'sidebar-rename-picker.png') + await inlineInput.press('Enter') + await expect(inlineInput).toHaveValue('Sidebar proof 😉 ') + await inlineInput.press('Enter') + await expect(orcaPage.getByText('Sidebar proof 😉', { exact: true }).first()).toBeVisible() + + await orcaPage.evaluate(() => { + const state = window.__store!.getState() + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((candidate) => candidate.id === state.activeWorktreeId) + if (!worktree) { + throw new Error('Active worktree not found') + } + state.openModal('edit-meta', { + worktreeId: worktree.id, + repoId: worktree.repoId, + currentDisplayName: worktree.displayName, + currentComment: worktree.comment, + focus: 'displayName' + }) + }) + + const detailsDialog = orcaPage.getByRole('dialog', { name: 'Edit Worktree Details' }) + const displayNameInput = detailsDialog.getByPlaceholder('Custom display name...') + await expect(displayNameInput).toBeFocused() + await displayNameInput.fill('Details proof') + await captureProof(orcaPage, testInfo, 'worktree-details-before.png') + await displayNameInput.pressSequentially(' :wink', { delay: 60 }) + const detailsSuggestions = detailsDialog.locator('[data-workspace-emoji-suggestions="true"]') + await expect(detailsSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible() + await captureProof(orcaPage, testInfo, 'worktree-details-picker.png') + await displayNameInput.press('Enter') + await expect(displayNameInput).toHaveValue('Details proof 😉 ') + await detailsDialog.getByRole('button', { name: 'Cancel' }).click() + + await orcaPage.evaluate(() => window.__store!.getState().openModal('worktree-palette')) + const palette = orcaPage.getByRole('dialog', { name: 'Jump to...' }) + const paletteInput = palette.getByPlaceholder( + 'Search chats, terminals, worktrees, settings, and actions...' + ) + await expect(paletteInput).toBeFocused() + await captureProof(orcaPage, testInfo, 'cmd-j-before.png') + await paletteInput.pressSequentially(':wink', { delay: 60 }) + const paletteSuggestions = palette.locator('[data-workspace-emoji-suggestions="true"]') + await expect(paletteSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible() + await captureProof(orcaPage, testInfo, 'cmd-j-picker.png') + await paletteInput.press('Enter') + await expect(paletteInput).toHaveValue('😉 ') + await expect(palette.getByText('Sidebar proof 😉', { exact: true }).first()).toBeVisible() + await orcaPage.waitForTimeout(750) + }) +}) From 4cca31f36fa5980d14046b2aea180e5d62577a3c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:21:18 -0700 Subject: [PATCH 024/271] fix(quick-open): compact Cmd+P file rows (#13454) --- src/renderer/src/components/QuickOpen.tsx | 6 ++++-- tests/e2e/quick-open-file-paths.spec.ts | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index ae46f4dc0a7..05e2613ce0d 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -119,6 +119,7 @@ export default function QuickOpen(): React.JSX.Element | null { placeholder={translate('auto.components.QuickOpen.1cb6ef47b7', 'Go to file...')} value={query} onValueChange={setQuery} + className="!h-9 !py-2" /> {loading ? ( @@ -155,13 +156,14 @@ export default function QuickOpen(): React.JSX.Element | null { key={item.path} value={item.path} onSelect={() => handleSelect(item.path)} - className="min-w-0 p-0" + // Why: CommandDialog's descendant rule otherwise adds 24px of vertical padding. + className="min-w-0 !p-0" > {/* Why: the trigger is this inner element, not the CommandItem. cmdk sets its own onPointerMove after spreading props, which drops the one Radix needs to open the tooltip. */} -
+
{/* shrink-0 + max-w-full: the directory gives up all of its width before the filename loses a character. */} diff --git a/tests/e2e/quick-open-file-paths.spec.ts b/tests/e2e/quick-open-file-paths.spec.ts index 879320e4e67..bb9703bc5cb 100644 --- a/tests/e2e/quick-open-file-paths.spec.ts +++ b/tests/e2e/quick-open-file-paths.spec.ts @@ -25,12 +25,18 @@ test('cmd+p quick open prioritizes the filename and reveals the full path on hov }) const dialog = orcaPage.getByRole('dialog', { name: 'Go to file' }) await expect(dialog).toBeVisible() + const inputBox = await dialog.locator('[data-cmdk-input-wrapper]').boundingBox() + expect(inputBox).not.toBeNull() + expect(inputBox!.height).toBeLessThanOrEqual(45) const input = dialog.locator('input[placeholder="Go to file..."]') await input.fill('QuickOpenTarget') const row = dialog.getByRole('option').filter({ hasText: 'QuickOpenTarget.tsx' }).first() await expect(row).toBeVisible() await expect(row).toContainText('packages/orca/src/renderer/src/components/navigation/') + const rowBox = await row.boundingBox() + expect(rowBox).not.toBeNull() + expect(rowBox!.height).toBeLessThanOrEqual(29) const rowText = await row.textContent() expect(rowText?.indexOf('QuickOpenTarget.tsx')).toBeLessThan( rowText?.indexOf('packages/orca/src/renderer/src/components/navigation/') ?? -1 From d9e84d88ef04341d3f91db9bec16b277e3cfce4b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:39:24 -0700 Subject: [PATCH 025/271] fix(persistence): salvage corrupt workspace session entries (#13431) --- src/main/persistence.test.ts | 162 ++++++- src/main/persistence.ts | 55 ++- src/renderer/src/store/slices/browser.ts | 34 +- .../slices/store-session-cascades.test.ts | 39 ++ .../slices/tab-group-reference-repair.ts | 78 ++++ .../tabs-hydration-group-validation.test.ts | 118 +++++ .../src/store/slices/tabs-hydration.ts | 12 +- .../workspace-session-browser-schema.ts | 83 ++++ src/shared/workspace-session-salvage.test.ts | 427 ++++++++++++++++++ src/shared/workspace-session-salvage.ts | 34 ++ src/shared/workspace-session-schema.test.ts | 56 ++- src/shared/workspace-session-schema.ts | 329 +++++++------- .../workspace-session-sleeping-agents.ts | 26 +- .../workspace-session-terminal-schema.test.ts | 7 +- src/shared/zod-salvage.ts | 132 ++++++ ...kspace-session-corrupt-tab-salvage.spec.ts | 137 ++++++ 16 files changed, 1505 insertions(+), 224 deletions(-) create mode 100644 src/renderer/src/store/slices/tab-group-reference-repair.ts create mode 100644 src/shared/workspace-session-browser-schema.ts create mode 100644 src/shared/workspace-session-salvage.test.ts create mode 100644 src/shared/workspace-session-salvage.ts create mode 100644 src/shared/zod-salvage.ts create mode 100644 tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b59ca6f5986..0e507090c1f 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -12142,21 +12142,177 @@ describe('Store host-partitioned workspace sessions', () => { expect(session.terminalTopologyRevisionByRepoId?.['repo-gone']).toBeUndefined() }) - it('drops a corrupt host partition to defaults without failing the others', async () => { + it('resets only the corrupt required field of a host partition, not the partition', async () => { + const worktreeId = 'repo-1::/worktree' writeDataFile({ schemaVersion: 1, workspaceSessionsByHostId: { 'runtime:good': makeHostSession('good-repo'), // activeRepoId must be string|null; a number fails the zod parse. - 'runtime:bad': { ...makeHostSession('x'), activeRepoId: 123 } + 'runtime:bad': { + ...makeHostSession('x'), + activeRepoId: 123, + tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'bad-host-tab', worktreeId })] } + } } }) const store = await createStore() expect(store.getWorkspaceSession('runtime:good').activeRepoId).toBe('good-repo') - // Bad partition collapses to defaults rather than poisoning the map. + // The unsalvageable field falls back to its default; the partition's tabs survive. expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull() + expect( + store.getWorkspaceSession('runtime:bad').tabsByWorktree[worktreeId]?.map((tab) => tab.id) + ).toEqual(['bad-host-tab']) + }) + + it('keeps every other worktree when the local session has a corrupt required field', async () => { + const worktreeId = 'repo-1::/worktree' + writeDataFile({ + schemaVersion: 1, + workspaceSession: { + ...makeHostSession('local-repo'), + // A projected/truncated write can leave a top-level field the wrong type; + // that must not cost every worktree's tabs the way a full reset did. + activeTabId: 42, + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'local-keep', worktreeId })] + } + } + }) + + const store = await createStore() + + const session = store.getWorkspaceSession('local') + expect(session.activeTabId).toBeNull() + expect(session.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['local-keep']) + }) + + type PersistedSessionsFile = { + workspaceSession?: { + tabsByWorktree?: Record + sleepingAgentSessionsByPaneKey?: Record + } + workspaceSessionsByHostId?: Record< + string, + { tabsByWorktree?: Record } + > + } + + // Why: flush() writes whatever the state hash says is dirty, so it passes even + // when nothing scheduled a save — it cannot see the repair write at all. Loading + // once first canonicalizes the profile (a second load of a canonical file + // schedules nothing), so a later rewrite proves the salvage scheduled it. + async function loadAndAwaitScheduledSave(): Promise { + vi.useFakeTimers() + try { + const store = await createStore() + vi.advanceTimersByTime(10_000) + await store.waitForPendingWrite() + } finally { + vi.useRealTimers() + } + } + + async function canonicalize(fixture: Record): Promise { + writeDataFile(fixture) + await loadAndAwaitScheduledSave() + const canonical = readFileSync(dataFile(), 'utf-8') + // Why: the save assertions below are only meaningful if a clean load schedules + // nothing. Prove that here rather than assume it — a future migration that + // dirtied every load would otherwise leave those tests silently vacuous. + await loadAndAwaitScheduledSave() + expect(readFileSync(dataFile(), 'utf-8')).toBe(canonical) + return JSON.parse(canonical) as PersistedSessionsFile + } + + it('schedules a save for a salvaged local session instead of re-salvaging every launch', async () => { + const worktreeId = 'repo-1::/worktree' + const profile = await canonicalize({ + schemaVersion: 1, + workspaceSession: { + ...makeHostSession('local-repo'), + tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab-keep', worktreeId })] } + } + }) + const tabs = profile.workspaceSession?.tabsByWorktree?.[worktreeId] + expect(tabs).toBeDefined() + tabs!.push({ id: 'tab-corrupt' }) + writeDataFile(profile) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await loadAndAwaitScheduledSave() + expect(warn).toHaveBeenCalledWith( + '[persistence] Salvaged workspace session; dropped corrupt entries:', + { count: 1, fields: ['tabsByWorktree'], detailsTruncated: false } + ) + expect(JSON.stringify(warn.mock.calls)).not.toContain(worktreeId) + } finally { + warn.mockRestore() + } + + const persisted = readDataFile() as PersistedSessionsFile + expect(persisted.workspaceSession?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)).toEqual([ + 'tab-keep' + ]) + }) + + it('schedules a save for salvaged host partitions', async () => { + const worktreeId = 'repo-1::/worktree' + const profile = await canonicalize({ + schemaVersion: 1, + workspaceSessionsByHostId: { + 'runtime:env-a': { + ...makeHostSession('runtime-repo'), + tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'runtime-keep', worktreeId })] } + }, + 'ssh:target-b': { + ...makeHostSession('ssh-repo'), + tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'ssh-keep', worktreeId })] } + } + } + }) + const partitions = profile.workspaceSessionsByHostId + const runtimeTabs = partitions?.['runtime:env-a']?.tabsByWorktree?.[worktreeId] + const sshTabs = partitions?.['ssh:target-b']?.tabsByWorktree?.[worktreeId] + expect(runtimeTabs).toBeDefined() + expect(sshTabs).toBeDefined() + runtimeTabs!.push({ id: 'runtime-corrupt' }) + sshTabs!.push({ id: 'ssh-corrupt' }) + const mutablePartitions = partitions as Record + mutablePartitions['runtime:broken'] = 'not a session' + writeDataFile(profile) + await loadAndAwaitScheduledSave() + + const persisted = (readDataFile() as PersistedSessionsFile).workspaceSessionsByHostId + expect( + persisted?.['runtime:env-a']?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id) + ).toEqual(['runtime-keep']) + expect(persisted?.['ssh:target-b']?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)).toEqual( + ['ssh-keep'] + ) + expect(persisted).not.toHaveProperty('runtime:broken') + }) + + it('writes back sleeping-agent records dropped during salvage', async () => { + const profile = await canonicalize({ + schemaVersion: 1, + workspaceSession: { + ...makeHostSession('local-repo'), + sleepingAgentSessionsByPaneKey: {} + } + }) + profile.workspaceSession!.sleepingAgentSessionsByPaneKey = { + 'tab-bad:leaf': { paneKey: 'different:leaf' } + } + writeDataFile(profile) + + await loadAndAwaitScheduledSave() + + const persisted = readDataFile() as PersistedSessionsFile + expect(persisted.workspaceSession?.sleepingAgentSessionsByPaneKey).toBeUndefined() }) }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 12856638d29..c2e36d3ce9d 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -134,7 +134,7 @@ import { ONBOARDING_FLOW_VERSION, ONBOARDING_FINAL_STEP } from '../shared/constants' -import { parseWorkspaceSession } from '../shared/workspace-session-schema' +import { parseWorkspaceSessionSalvaging } from '../shared/workspace-session-salvage' import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode' import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' @@ -555,15 +555,27 @@ function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatc ) } +function workspaceSessionSalvageLogDetails(result: { + droppedCount: number + droppedPaths: string[] +}): { count: number; fields: string[]; detailsTruncated: boolean } { + return { + count: result.droppedCount, + fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))], + detailsTruncated: result.droppedCount > result.droppedPaths.length + } +} + /** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ function parseWorkspaceSessionsByHostId( raw: unknown, defaults: WorkspaceSessionState -): Partial> { +): { partitions: Partial>; repaired: boolean } { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return {} + return { partitions: {}, repaired: raw !== undefined } } + let repaired = false const partitions: Partial> = {} for (const [key, value] of Object.entries(raw as Record)) { const hostId = normalizeExecutionHostId(key) @@ -571,17 +583,25 @@ function parseWorkspaceSessionsByHostId( if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { continue } - const result = parseWorkspaceSession(value) + const result = parseWorkspaceSessionSalvaging(value) if (!result.ok) { + repaired = true console.error( `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, result.error ) continue } + if (result.droppedCount > 0) { + console.warn( + `[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`, + workspaceSessionSalvageLogDetails(result) + ) + repaired = true + } partitions[hostId] = { ...defaults, ...result.value } } - return partitions + return { partitions, repaired } } function backupPath(dataFile: string, index: number): string { @@ -3651,7 +3671,7 @@ export class Store { if (parsed.workspaceSession === undefined) { return defaults.workspaceSession } - const result = parseWorkspaceSession(parsed.workspaceSession) + const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession) if (!result.ok) { console.error( '[persistence] Corrupt workspace session, using defaults:', @@ -3659,13 +3679,28 @@ export class Store { ) return defaults.workspaceSession } + if (result.droppedCount > 0) { + console.warn( + '[persistence] Salvaged workspace session; dropped corrupt entries:', + workspaceSessionSalvageLogDetails(result) + ) + // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. + this.loadNeedsSave = true + } return { ...defaults.workspaceSession, ...result.value } })(), // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. - workspaceSessionsByHostId: parseWorkspaceSessionsByHostId( - parsed.workspaceSessionsByHostId, - defaults.workspaceSession - ), + workspaceSessionsByHostId: (() => { + const { partitions, repaired } = parseWorkspaceSessionsByHostId( + parsed.workspaceSessionsByHostId, + defaults.workspaceSession + ) + if (repaired) { + // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. + this.loadNeedsSave = true + } + return partitions + })(), sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) ? parsed.deletedSshConfigAliases.filter( diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 787fd320920..78a7a31b358 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -1651,21 +1651,25 @@ export const createBrowserSlice: StateCreator = } const hydratedTabs: BrowserWorkspace[] = [] for (const tab of tabs) { - const persistedPages = persistedPagesByWorkspace[tab.id] ?? [ - { - id: createBrowserUuid(), - workspaceId: tab.id, - worktreeId, - url: normalizeUrl(tab.url), - title: tab.title, - loading: false, - faviconUrl: tab.faviconUrl ?? null, - canGoBack: tab.canGoBack, - canGoForward: tab.canGoForward, - loadError: tab.loadError ?? null, - createdAt: tab.createdAt - } satisfies BrowserPage - ] + // Salvage can leave an empty page array; hydrate it like a missing array. + const storedPages = persistedPagesByWorkspace[tab.id] + const persistedPages = storedPages?.length + ? storedPages + : [ + { + id: createBrowserUuid(), + workspaceId: tab.id, + worktreeId, + url: normalizeUrl(tab.url), + title: tab.title, + loading: false, + faviconUrl: tab.faviconUrl ?? null, + canGoBack: tab.canGoBack, + canGoForward: tab.canGoForward, + loadError: tab.loadError ?? null, + createdAt: tab.createdAt + } satisfies BrowserPage + ] const nextPages = persistedPages.map((page) => { // Why: in-memory hydration callers can bypass the persistence schema's unknown-key stripping. const { allowWindowClose: _legacyAllowWindowClose, ...persistedPage } = 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 3d9a759e1d2..06cfa17bfc8 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -724,6 +724,45 @@ describe('hydrateBrowserSession', () => { expect(s.activeBrowserTabId).toBe('browser-1') }) + it('synthesizes a page for a browser workspace whose persisted page list is empty', () => { + // Why: session salvage drops a corrupt page by rebuilding the array, so the + // key survives holding []. Treating that as "has pages" restores a workspace + // with no page at all — a dead about:blank tab nothing prunes or reloads. + const store = createTestStore() + const validWt = 'repo1::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: validWt, repoId: 'repo1', path: '/path/wt1' })] + }, + activeWorktreeId: validWt + }) + + store.getState().hydrateBrowserSession({ + activeRepoId: 'repo1', + activeWorktreeId: validWt, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + browserTabsByWorktree: { + [validWt]: [ + makeBrowserTab({ id: 'browser-1', worktreeId: validWt, url: 'https://example.com' }) + ] + }, + browserPagesByWorkspace: { 'browser-1': [] } + }) + + const s = store.getState() + expect(s.browserPagesByWorkspace['browser-1']).toHaveLength(1) + expect(s.browserPagesByWorkspace['browser-1'][0].url).toBe('https://example.com') + expect(s.browserTabsByWorktree[validWt][0].activePageId).toBe( + s.browserPagesByWorkspace['browser-1'][0].id + ) + }) + it('drops legacy window close bypass state during hydration', () => { const store = createTestStore() const validWt = 'repo1::/path/wt1' diff --git a/src/renderer/src/store/slices/tab-group-reference-repair.ts b/src/renderer/src/store/slices/tab-group-reference-repair.ts new file mode 100644 index 00000000000..42a742cc724 --- /dev/null +++ b/src/renderer/src/store/slices/tab-group-reference-repair.ts @@ -0,0 +1,78 @@ +import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/types' +import { createBrowserUuid } from '@/lib/browser-uuid' + +function collectLayoutGroupIds(node: TabGroupLayoutNode, groupIds: Set): void { + if (node.type === 'leaf') { + groupIds.add(node.groupId) + return + } + collectLayoutGroupIds(node.first, groupIds) + collectLayoutGroupIds(node.second, groupIds) +} + +export function layoutSpanningGroups( + groups: readonly TabGroup[], + existing?: TabGroupLayoutNode | null +): TabGroupLayoutNode { + const first = existing ?? { type: 'leaf', groupId: groups[0].id } + const laidOutGroupIds = new Set() + collectLayoutGroupIds(first, laidOutGroupIds) + return groups + .filter((group) => !laidOutGroupIds.has(group.id)) + .reduce( + (first, group) => ({ + type: 'split', + direction: 'horizontal', + first, + second: { type: 'leaf', groupId: group.id } + }), + first + ) +} + +/** Re-home tabs stranded by a dropped group so hydration cannot render a blank workspace. */ +export function adoptGrouplessTabs( + tabsByWorktree: Record, + groupsByWorktree: Record, + activeGroupIdByWorktree: Record, + layoutByWorktree: Record +): void { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const groups = groupsByWorktree[worktreeId] ?? [] + const owningGroupIdByTabId = new Map() + for (const group of groups) { + for (const tabId of group.tabOrder) { + owningGroupIdByTabId.set(tabId, owningGroupIdByTabId.get(tabId) ?? group.id) + } + } + const orphanIds = tabs.filter((tab) => !owningGroupIdByTabId.has(tab.id)).map((tab) => tab.id) + const host: TabGroup = groups[0] ?? { + id: createBrowserUuid(), + worktreeId, + activeTabId: null, + tabOrder: [], + recentTabIds: [] + } + const adopted = orphanIds.length + ? { + ...host, + tabOrder: [...host.tabOrder, ...orphanIds], + activeTabId: host.activeTabId ?? orphanIds[0] + } + : host + for (const tabId of orphanIds) { + owningGroupIdByTabId.set(tabId, adopted.id) + } + tabsByWorktree[worktreeId] = tabs.map((tab) => { + const owningGroupId = owningGroupIdByTabId.get(tab.id) + return owningGroupId && tab.groupId !== owningGroupId + ? { ...tab, groupId: owningGroupId } + : tab + }) + if (orphanIds.length) { + groupsByWorktree[worktreeId] = [adopted, ...groups.slice(1)] + activeGroupIdByWorktree[worktreeId] ??= adopted.id + layoutByWorktree[worktreeId] ??= { type: 'leaf', groupId: adopted.id } + } + } +} diff --git a/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts b/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts index f927db73b8d..dca253f4733 100644 --- a/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts +++ b/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts @@ -96,3 +96,121 @@ describe('buildHydratedTabState group validation', () => { expect(group.tabOrder).toEqual(['t1']) }) }) + +describe('buildHydratedTabState referential repair', () => { + const tab = (id: string, groupId: string, sortOrder: number) => ({ + id, + entityId: id, + groupId, + worktreeId: 'w1', + contentType: 'terminal' as const, + label: id, + customLabel: null, + color: null, + sortOrder, + createdAt: 1 + }) + + function leafGroupIds(node: unknown): string[] { + const layout = node as + | { type: 'leaf'; groupId: string } + | { type: 'split'; first: unknown; second: unknown } + return layout.type === 'leaf' + ? [layout.groupId] + : [...leafGroupIds(layout.first), ...leafGroupIds(layout.second)] + } + + it('keeps tabs reachable when a worktree lost every tab group', () => { + // Why: session salvage drops a corrupt group record and leaves its tabs + // behind. Groupless tabs render nowhere yet still count as renderable, so the + // auto-create rescue never fires and the worktree body comes back blank. + const result = buildHydratedTabState( + { + ...makeBaseSession(), + unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g1', 1)] }, + tabGroups: { w1: [] } + }, + new Set(['w1']) + ) + + const groups = result.groupsByWorktree.w1 + expect(groups).toHaveLength(1) + expect(groups[0].tabOrder).toEqual(['t1', 't2']) + expect(groups[0].activeTabId).toBe('t1') + expect(result.activeGroupIdByWorktree.w1).toBe(groups[0].id) + expect(leafGroupIds(result.layoutByWorktree.w1)).toEqual([groups[0].id]) + expect(result.unifiedTabsByWorktree.w1.map((t) => t.groupId)).toEqual([ + groups[0].id, + groups[0].id + ]) + }) + + it('adopts tabs stranded by one dropped group into a surviving group', () => { + const result = buildHydratedTabState( + { + ...makeBaseSession(), + unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g2', 1)] }, + tabGroups: { w1: [{ id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }] } + }, + new Set(['w1']) + ) + + expect(result.groupsByWorktree.w1.map((group) => group.id)).toEqual(['g1']) + expect(result.groupsByWorktree.w1[0].tabOrder).toEqual(['t1', 't2']) + expect(result.unifiedTabsByWorktree.w1.map((t) => t.groupId)).toEqual(['g1', 'g1']) + }) + + it('repairs a tab groupId when a surviving group already owns the tab', () => { + const result = buildHydratedTabState( + { + ...makeBaseSession(), + unifiedTabs: { w1: [tab('t1', 'g-dropped', 0)] }, + tabGroups: { w1: [{ id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }] } + }, + new Set(['w1']) + ) + + expect(result.groupsByWorktree.w1[0].tabOrder).toEqual(['t1']) + expect(result.unifiedTabsByWorktree.w1[0].groupId).toBe('g1') + }) + + it('spans every surviving group when the persisted layout is gone', () => { + // Why: salvage can drop a corrupt tabGroupLayouts entry while both groups + // survive; a fallback leaf naming only the first hides the second group and + // every tab in it, with nothing downstream to notice. + const result = buildHydratedTabState( + { + ...makeBaseSession(), + unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g2', 1)] }, + tabGroups: { + w1: [ + { id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }, + { id: 'g2', worktreeId: 'w1', activeTabId: 't2', tabOrder: ['t2'] } + ] + } + }, + new Set(['w1']) + ) + + expect(leafGroupIds(result.layoutByWorktree.w1)).toEqual(['g1', 'g2']) + }) + + it('adds surviving groups omitted by a partial persisted layout', () => { + const result = buildHydratedTabState( + { + ...makeBaseSession(), + unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g2', 1)] }, + tabGroups: { + w1: [ + { id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }, + { id: 'g2', worktreeId: 'w1', activeTabId: 't2', tabOrder: ['t2'] } + ] + }, + tabGroupLayouts: { w1: { type: 'leaf', groupId: 'g1' } } + }, + new Set(['w1']) + ) + + expect(leafGroupIds(result.layoutByWorktree.w1)).toEqual(['g1', 'g2']) + }) +}) diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index e6b1ae2474e..2fb85ab3808 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -6,6 +6,7 @@ import type { } from '../../../../shared/types' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { createBrowserUuid } from '@/lib/browser-uuid' +import { adoptGrouplessTabs, layoutSpanningGroups } from './tab-group-reference-repair' import { dedupeTabOrder, getPersistedEditFileIdsByWorktree, @@ -176,15 +177,12 @@ function hydrateUnifiedFormat( const hydratedLayout = session.tabGroupLayouts?.[worktreeId] ? pruneTabGroupLayoutForGroups(session.tabGroupLayouts[worktreeId], hydratedGroupIds) : null - layoutByWorktree[worktreeId] = hydratedLayout ?? { - type: 'leaf', - // Why: if transient-only groups were removed during hydration, the - // persisted split tree can collapse to a single surviving group. The - // fallback leaf keeps restore aligned with the remaining real tabs. - groupId: hydratedGroups[0].id - } + // A partial layout must still render every surviving group. + layoutByWorktree[worktreeId] = layoutSpanningGroups(hydratedGroups, hydratedLayout) } + adoptGrouplessTabs(tabsByWorktree, groupsByWorktree, activeGroupIdByWorktree, layoutByWorktree) + return { unifiedTabsByWorktree: tabsByWorktree, groupsByWorktree, diff --git a/src/shared/workspace-session-browser-schema.ts b/src/shared/workspace-session-browser-schema.ts new file mode 100644 index 00000000000..19aaeb0ae82 --- /dev/null +++ b/src/shared/workspace-session-browser-schema.ts @@ -0,0 +1,83 @@ +/* Why: the browser slice of the persisted workspace session. Split out of + * workspace-session-schema.ts to keep that file inside its line budget; the + * schemas themselves are unchanged apart from the per-entry tolerance the + * session schema now declares everywhere. */ +import { z } from 'zod' +import type { BrowserWorkspace } from './types' +import { normalizeBrowserHistoryEntries } from './workspace-session-browser-history' +import { salvagingArray } from './zod-salvage' + +const browserLoadErrorSchema = z.object({ + code: z.number(), + description: z.string(), + validatedUrl: z.string() +}) + +const browserViewportPresetIdSchema = z.enum([ + 'mobile-s', + 'mobile-m', + 'mobile-l', + 'tablet', + 'laptop', + 'laptop-l', + 'desktop' +]) + +// Why: the z.ZodType cast only aligns the static type — it +// does NOT let new fields survive parsing. z.object strips unknown keys, so +// every additive field must be listed below (optional+nullable) or it is +// dropped on restore. +export const browserWorkspaceSchema: z.ZodType = z.object({ + id: z.string(), + worktreeId: z.string(), + label: z.string().optional(), + sessionProfileId: z.string().nullable().optional(), + // Why: optional+nullable so pre-field sessions still validate; without this + // zod strips the persisted partition on restore, and an isolated tab whose + // profile mirror is stale at startup would silently fall back to the shared + // default partition — reopening the storage leak (#6923) across restarts. + sessionPartition: z.string().nullable().optional(), + activePageId: z.string().nullable().optional(), + pageIds: z.array(z.string()).optional(), + url: z.string(), + title: z.string(), + loading: z.boolean(), + faviconUrl: z.string().nullable(), + canGoBack: z.boolean(), + canGoForward: z.boolean(), + loadError: browserLoadErrorSchema.nullable(), + createdAt: z.number() +}) + +export const browserPageSchema = z.object({ + id: z.string(), + workspaceId: z.string(), + worktreeId: z.string(), + url: z.string(), + title: z.string(), + loading: z.boolean(), + faviconUrl: z.string().nullable(), + canGoBack: z.boolean(), + canGoForward: z.boolean(), + loadError: browserLoadErrorSchema.nullable(), + createdAt: z.number(), + // Why: explicit null marks a browser page as client-local even when its + // worktree is remote-owned; older sessions omit it and keep inferred runtime. + browserRuntimeEnvironmentId: z.string().nullable().optional(), + // Why: optional+nullable so sessions persisted before viewport presets were + // added still validate; without this, zod would strip the field during + // restore and reset the user's chosen preset on every app restart. + viewportPresetId: browserViewportPresetIdSchema.nullable().optional() +}) + +const browserHistoryEntrySchema = z.object({ + url: z.string(), + normalizedUrl: z.string(), + title: z.string(), + lastVisitedAt: z.number(), + visitCount: z.number() +}) + +export const browserHistoryEntriesSchema = salvagingArray(browserHistoryEntrySchema).transform( + (entries) => normalizeBrowserHistoryEntries(entries) +) diff --git a/src/shared/workspace-session-salvage.test.ts b/src/shared/workspace-session-salvage.test.ts new file mode 100644 index 00000000000..876f8bb7305 --- /dev/null +++ b/src/shared/workspace-session-salvage.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { parseWorkspaceSessionSalvaging } from './workspace-session-salvage' +import { collectSalvageDrops, salvagingArray } from './zod-salvage' + +const WT = 'repo-1::/home/user/project' + +function terminalTab(id: string, overrides: Record = {}): Record { + return { + id, + ptyId: null, + worktreeId: WT, + title: 'Terminal', + defaultTitle: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1_700_000_000_000, + ...overrides + } +} + +function baseSession(overrides: Record = {}): Record { + return { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...overrides + } +} + +describe('parseWorkspaceSessionSalvaging', () => { + it('restores outer diagnostics after a nested collection', () => { + const schema = salvagingArray(z.string()) + const outer = collectSalvageDrops(() => { + schema.parse([1]) + const inner = collectSalvageDrops(() => schema.parse([2])) + schema.parse([3]) + return inner + }) + + expect(outer.droppedCount).toBe(2) + expect(outer.value.droppedCount).toBe(1) + }) + + it('returns a valid session unchanged with nothing dropped', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ tabsByWorktree: { [WT]: [terminalTab('tab-1')] } }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual([]) + expect(result.value.tabsByWorktree[WT]).toHaveLength(1) + } + }) + + it('drops a tab record missing required fields and keeps the rest of the session', () => { + const truncated = { + id: 'tab-bad', + ptyId: null, + worktreeId: WT, + title: 'Terminal', + sortOrder: 0, + generation: 3, + startupCwd: '/home/user/project' + } + const result = parseWorkspaceSessionSalvaging( + baseSession({ + tabsByWorktree: { [WT]: [terminalTab('tab-1'), terminalTab('tab-2'), truncated] }, + sleepingAgentSessionsByPaneKey: {} + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual([`tabsByWorktree.${WT}.2`]) + expect(result.value.tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual(['tab-1', 'tab-2']) + } + }) + + it('reports sleeping-agent records removed during normalization', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ + sleepingAgentSessionsByPaneKey: { + 'tab-bad:leaf': { paneKey: 'different:leaf' } + } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual(['sleepingAgentSessionsByPaneKey.tab-bad:leaf']) + expect(result.value.sleepingAgentSessionsByPaneKey).toBeUndefined() + } + }) + + it('drops only the corrupt leaf pty mapping and keeps the rest of the tab layout', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ + tabsByWorktree: { [WT]: [terminalTab('tab-1')] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-1': 'pty-1', 'leaf-2': 42 } + } + } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual(['terminalLayoutsByTabId.tab-1.ptyIdsByLeafId.leaf-2']) + const layout = result.value.terminalLayoutsByTabId['tab-1'] + expect(layout?.ptyIdsByLeafId).toEqual({ 'leaf-1': 'pty-1' }) + expect(layout?.root).toEqual({ type: 'leaf', leafId: 'leaf-1' }) + } + }) + + it('drops the containing entry when the corruption sits in one of its required fields', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ + terminalLayoutsByTabId: { + 'tab-bad': { + root: { type: 'leaf', leafId: 42 }, + activeLeafId: null, + expandedLeafId: null + }, + 'tab-good': { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + } + } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + // Why: the entry is the smallest self-contained unit, so dropped counts + // reflect distinct corrupt records rather than symptoms. + expect(result.droppedPaths).toEqual(['terminalLayoutsByTabId.tab-bad']) + expect(result.value.terminalLayoutsByTabId['tab-bad']).toBeUndefined() + expect(result.value.terminalLayoutsByTabId['tab-good']?.root).toEqual({ + type: 'leaf', + leafId: 'leaf-1' + }) + } + }) + + it('salvages systemic single-field corruption without inflating the dropped count', () => { + const layouts: Record = {} + for (let i = 0; i < 20; i += 1) { + layouts[`tab-${i}`] = { + root: { type: 'leaf', leafId: i }, + activeLeafId: null, + expandedLeafId: null + } + } + const result = parseWorkspaceSessionSalvaging(baseSession({ terminalLayoutsByTabId: layouts })) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.terminalLayoutsByTabId).toEqual({}) + expect(result.droppedPaths).toHaveLength(20) + } + }) + + it('reports one drop when a record raises both a bad-field and a missing-field issue', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ terminalSurfaceTombstonesByPaneKey: { 'tab-1:leaf-1': { worktreeId: 42 } } }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual(['terminalSurfaceTombstonesByPaneKey.tab-1:leaf-1']) + expect(result.value.terminalSurfaceTombstonesByPaneKey).toEqual({}) + } + }) + + it('reports one drop when two fields of the same record are corrupt', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ + terminalSurfaceTombstonesByPaneKey: { + 'tab-1:leaf-1': { + worktreeId: WT, + parentTabId: 'tab-1', + leafId: 'leaf-1', + ptyId: 'pty-1', + incarnationId: '', + retiredAt: -5 + } + } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual(['terminalSurfaceTombstonesByPaneKey.tab-1:leaf-1']) + expect(result.value.terminalSurfaceTombstonesByPaneKey).toEqual({}) + } + }) + + it('reports a dotted map key and its same-named nested path independently', () => { + // Why: map keys are user data, so 'a.root' and the nested path a → root are + // different entries that read alike once joined. + const result = parseWorkspaceSessionSalvaging( + baseSession({ + terminalLayoutsByTabId: { + 'a.root': 'not-an-object', + a: { root: 42, activeLeafId: null, expandedLeafId: null } + } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths.toSorted()).toEqual([ + 'terminalLayoutsByTabId.a', + 'terminalLayoutsByTabId.a.root' + ]) + expect(result.value.terminalLayoutsByTabId).toEqual({}) + } + }) + + it('drops an invalid unified tab entry without touching sibling worktrees', () => { + const goodUnified = { + id: 'tab-1', + entityId: 'tab-1', + groupId: 'group-1', + worktreeId: WT, + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1_700_000_000_000 + } + const missingCustomLabel = { ...goodUnified, id: 'tab-2', entityId: 'tab-2' } as Record< + string, + unknown + > + delete missingCustomLabel.customLabel + const result = parseWorkspaceSessionSalvaging( + baseSession({ + tabsByWorktree: { [WT]: [terminalTab('tab-1')] }, + unifiedTabs: { [WT]: [goodUnified, missingCustomLabel], 'repo-2::/other': [] } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual([`unifiedTabs.${WT}.1`]) + expect(result.value.unifiedTabs?.[WT]?.map((tab) => tab.id)).toEqual(['tab-1']) + expect(result.value.unifiedTabs?.['repo-2::/other']).toEqual([]) + } + }) + + it('drops a corrupt map value by its key', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ + terminalPtyIncarnationsByPaneKey: { 'tab-1:leaf-1': 'inc-1', 'tab-2:leaf-2': 123 } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual(['terminalPtyIncarnationsByPaneKey.tab-2:leaf-2']) + expect(result.value.terminalPtyIncarnationsByPaneKey).toEqual({ 'tab-1:leaf-1': 'inc-1' }) + } + }) + + it('salvages multiple corrupt entries across different maps in one load', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ + tabsByWorktree: { [WT]: [terminalTab('tab-1'), { id: 'tab-bad' }] }, + terminalPtyIncarnationsByPaneKey: { 'tab-1:leaf-1': 42 } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toHaveLength(2) + expect(result.value.tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual(['tab-1']) + } + }) + + it('salvages rather than throwing on a payload large enough to overflow the validator', () => { + // Why: this parse runs in the Store constructor, so an escaping RangeError is + // an unrecoverable launch failure. Per-entry validation never accumulates the + // issue list that used to overflow. + const worktreeId = 'repo-1::/huge' + const tabs = Array.from({ length: 200_000 }, (_, i) => ({ id: `bad-${i}` })) + const result = parseWorkspaceSessionSalvaging( + baseSession({ tabsByWorktree: { [worktreeId]: tabs } }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedCount).toBe(200_000) + expect(result.droppedPaths).toHaveLength(100) + expect(result.value.tabsByWorktree[worktreeId]).toEqual([]) + } + }) + + it('fails for a payload that is not an object', () => { + expect(parseWorkspaceSessionSalvaging('not a session').ok).toBe(false) + expect(parseWorkspaceSessionSalvaging(null).ok).toBe(false) + }) + + it('drops an optional top-level field whose value is the wrong type', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ terminalTopologyRevisionByRepoId: 'nope' }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual(['terminalTopologyRevisionByRepoId']) + expect(result.value.terminalTopologyRevisionByRepoId).toBeUndefined() + // Why: an explicit undefined key would shadow the caller's default in the + // `{ ...defaults, ...value }` spread both call sites do. + expect(Object.hasOwn(result.value, 'terminalTopologyRevisionByRepoId')).toBe(false) + } + }) + + it('salvages systemic corruption far larger than any single-entry budget', () => { + // Why: the reported failure was one bad record, but a bad writer projects the + // same wrong shape across every entry it touches. The dropped count is + // unbounded so that case stays a salvage instead of a full-session reset. + const incarnations: Record = {} + for (let i = 0; i < 400; i += 1) { + incarnations[`tab-${i}:leaf-${i}`] = i % 2 === 0 ? i : `inc-${i}` + } + const result = parseWorkspaceSessionSalvaging( + baseSession({ terminalPtyIncarnationsByPaneKey: incarnations }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedCount).toBe(200) + expect(result.droppedPaths).toHaveLength(100) + expect(Object.keys(result.value.terminalPtyIncarnationsByPaneKey ?? {})).toHaveLength(200) + } + }) + + it('drops many corrupt tab records across worktrees in a single session load', () => { + const tabsByWorktree: Record = {} + for (let w = 0; w < 20; w += 1) { + const worktreeId = `repo-1::/w${w}` + tabsByWorktree[worktreeId] = [ + terminalTab(`good-${w}`, { worktreeId }), + { id: `bad-a-${w}`, worktreeId }, + terminalTab(`good2-${w}`, { worktreeId }), + { id: `bad-b-${w}`, worktreeId } + ] + } + const result = parseWorkspaceSessionSalvaging(baseSession({ tabsByWorktree })) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toHaveLength(40) + expect(result.value.tabsByWorktree['repo-1::/w7']?.map((tab) => tab.id)).toEqual([ + 'good-7', + 'good2-7' + ]) + } + }) + + it('keeps the rest of the session when a required top-level field is unsalvageable', () => { + // Why: without a fallback, one bad legacy `tabsByWorktree` would still cost + // every worktree's unified tabs, groups and layouts. + const result = parseWorkspaceSessionSalvaging( + baseSession({ + tabsByWorktree: 'nope', + activeRepoId: 42, + terminalPtyIncarnationsByPaneKey: { 'tab-1:leaf-1': 'inc-1' } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths.toSorted()).toEqual(['activeRepoId', 'tabsByWorktree']) + expect(result.value.tabsByWorktree).toEqual({}) + expect(result.value.activeRepoId).toBeNull() + expect(result.value.terminalPtyIncarnationsByPaneKey).toEqual({ 'tab-1:leaf-1': 'inc-1' }) + } + }) + + it('still rejects a foreign object payload rather than posing as a repaired session', () => { + // Why: a fallback repairs a field we could not read, it must never manufacture + // a session out of an unrelated JSON blob that simply lacks every field. + expect(parseWorkspaceSessionSalvaging({ unrelated: 'payload', count: 3 }).ok).toBe(false) + }) + + it('drops a whole layout entry when the corruption sits inside a recursive union', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ + tabGroupLayouts: { + [WT]: { + type: 'split', + direction: 'row', + first: { type: 'split', direction: 'column', first: { type: 'leaf', groupId: 42 } } + }, + 'repo-2::/other': { type: 'leaf', groupId: 'group-ok' } + } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual([`tabGroupLayouts.${WT}`]) + expect(result.value.tabGroupLayouts?.[WT]).toBeUndefined() + expect(result.value.tabGroupLayouts?.['repo-2::/other']).toBeDefined() + } + }) + + it('drops a recursive entry whose validator overflows instead of resetting the session', () => { + let layout: Record = { type: 'leaf', groupId: 'group-deep' } + for (let depth = 0; depth < 3_000; depth += 1) { + layout = { + type: 'split', + direction: 'horizontal', + first: layout, + second: { type: 'leaf', groupId: `group-${depth}` } + } + } + const result = parseWorkspaceSessionSalvaging( + baseSession({ + tabsByWorktree: { [WT]: [terminalTab('tab-keep')] }, + tabGroupLayouts: { [WT]: layout } + }) + ) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual([`tabGroupLayouts.${WT}`]) + expect(result.value.tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual(['tab-keep']) + expect(result.value.tabGroupLayouts).toEqual({}) + } + }) +}) diff --git a/src/shared/workspace-session-salvage.ts b/src/shared/workspace-session-salvage.ts new file mode 100644 index 00000000000..66b6a6374e9 --- /dev/null +++ b/src/shared/workspace-session-salvage.ts @@ -0,0 +1,34 @@ +import type { WorkspaceSessionState } from './types' +import { + describeWorkspaceSessionError, + safeParseWorkspaceSession, + WORKSPACE_SESSION_UNVALIDATABLE +} from './workspace-session-schema' +import { collectSalvageDrops } from './zod-salvage' + +export type SalvagedWorkspaceSession = + | { ok: true; value: WorkspaceSessionState; droppedPaths: string[]; droppedCount: number } + | { ok: false; error: string } + +/** Remove undefined keys that would shadow caller defaults during object spread. */ +function withoutSalvagedAwayFields(session: WorkspaceSessionState): WorkspaceSessionState { + return Object.fromEntries( + Object.entries(session).filter(([, value]) => value !== undefined) + ) as WorkspaceSessionState +} + +/** Validate a session, preserving valid entries and reporting bounded repair diagnostics. */ +export function parseWorkspaceSessionSalvaging(raw: unknown): SalvagedWorkspaceSession { + const { + value: result, + droppedPaths, + droppedCount + } = collectSalvageDrops(() => safeParseWorkspaceSession(raw)) + if (!result) { + return { ok: false, error: WORKSPACE_SESSION_UNVALIDATABLE } + } + if (!result.success) { + return { ok: false, error: describeWorkspaceSessionError(result.error) } + } + return { ok: true, value: withoutSalvagedAwayFields(result.data), droppedPaths, droppedCount } +} diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 72c7f40d2a5..9a1291a9d67 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -40,7 +40,7 @@ describe('parseWorkspaceSession', () => { } }) - it('rejects blank external SSH file ownership', () => { + it('drops an open file with blank external SSH ownership, keeping the session', () => { const result = parseWorkspaceSession({ activeRepoId: null, activeWorktreeId: 'wt', @@ -60,7 +60,10 @@ describe('parseWorkspaceSession', () => { } }) - expect(result.ok).toBe(false) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.openFilesByWorktree?.wt).toEqual([]) + } }) it('accepts a fully populated session with optional fields', () => { @@ -194,7 +197,7 @@ describe('parseWorkspaceSession', () => { } }) - it('rejects a session where ptyId is a number (schema drift)', () => { + it('drops a tab where ptyId is a number (schema drift) without failing the session', () => { const result = parseWorkspaceSession({ activeRepoId: null, activeWorktreeId: null, @@ -215,9 +218,9 @@ describe('parseWorkspaceSession', () => { }, terminalLayoutsByTabId: {} }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error).toContain('ptyId') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.tabsByWorktree.wt).toEqual([]) } }) @@ -406,6 +409,47 @@ describe('parseWorkspaceSession', () => { expect(parseWorkspaceSession(42).ok).toBe(false) }) + it('drops one truncated tab without discarding other persisted worktrees', () => { + const validTab = { + id: 'tab-good', + ptyId: null, + worktreeId: 'worktree-good', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1_700_000_000_000 + } + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'worktree-good', + activeTabId: 'tab-good', + tabsByWorktree: { + 'worktree-good': [validTab], + 'worktree-corrupt': [ + { + id: 'tab-truncated', + ptyId: null, + worktreeId: 'worktree-corrupt', + title: 'Terminal', + sortOrder: 0, + generation: 3, + startupCwd: '/workspace' + } + ] + }, + terminalLayoutsByTabId: {} + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.tabsByWorktree).toEqual({ + 'worktree-good': [validTab], + 'worktree-corrupt': [] + }) + } + }) + it('drops bad lastVisitedAtByWorktreeId entries rather than failing the session', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 7a24ad57505..446e19270da 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -5,12 +5,14 @@ * "reject and fall back to defaults" point so garbage never reaches React. * * Policy: be tolerant of extra fields (future builds may add more) but strict - * about the types of fields we actually read. Unknown enum values, wrong types, - * and wrong shapes all collapse to "use defaults" — never throw into main. + * about the types of fields we actually read. Where a field holds a collection + * of independent records, tolerance is declared on the field itself (see + * ./zod-salvage): a corrupt entry is dropped and the rest of the session + * survives, because one bad tab record must not cost every worktree its state. + * Only a payload that is not a session at all falls back to defaults. */ import { z } from 'zod' import type { - BrowserWorkspace, TabGroupLayoutNode, TerminalPaneLayoutNode, TuiAgent, @@ -20,9 +22,14 @@ import type { import { isValidTerminalTabId } from './terminal-tab-id' import { parseExecutionHostId, type ExecutionHostId } from './execution-host' import { isTuiAgent } from './tui-agent-config' -import { normalizeBrowserHistoryEntries } from './workspace-session-browser-history' import { isWorkspaceKey } from './workspace-scope' +import { + browserHistoryEntriesSchema, + browserPageSchema, + browserWorkspaceSchema +} from './workspace-session-browser-schema' import { sleepingAgentSessionsByPaneKeySchema } from './workspace-session-sleeping-agents' +import { salvagedField, salvagedOptional, salvagingArray, salvagingRecord } from './zod-salvage' // ─── Terminal pane layout (recursive) ─────────────────────────────── @@ -53,14 +60,16 @@ const terminalPaneLayoutNodeSchema: z.ZodType = z.lazy(( ]) ) +const leafStringsSchema = salvagingRecord(z.string(), z.string()) + const terminalLayoutSnapshotSchema = z.object({ root: terminalPaneLayoutNodeSchema.nullable(), activeLeafId: z.string().nullable(), expandedLeafId: z.string().nullable(), - ptyIdsByLeafId: z.record(z.string(), z.string()).optional(), - buffersByLeafId: z.record(z.string(), z.string()).optional(), - scrollbackRefsByLeafId: z.record(z.string(), z.string()).optional(), - titlesByLeafId: z.record(z.string(), z.string()).optional() + ptyIdsByLeafId: salvagedOptional('ptyIdsByLeafId', leafStringsSchema), + buffersByLeafId: salvagedOptional('buffersByLeafId', leafStringsSchema), + scrollbackRefsByLeafId: salvagedOptional('scrollbackRefsByLeafId', leafStringsSchema), + titlesByLeafId: salvagedOptional('titlesByLeafId', leafStringsSchema) }) // ─── Terminal tab (legacy) ────────────────────────────────────────── @@ -187,172 +196,170 @@ const persistedOpenFileSchema = z.object({ liveTail: z.boolean().optional() }) -// ─── Browser ──────────────────────────────────────────────────────── - -const browserLoadErrorSchema = z.object({ - code: z.number(), - description: z.string(), - validatedUrl: z.string() -}) - -const browserViewportPresetIdSchema = z.enum([ - 'mobile-s', - 'mobile-m', - 'mobile-l', - 'tablet', - 'laptop', - 'laptop-l', - 'desktop' -]) - -// Why: the z.ZodType cast only aligns the static type — it -// does NOT let new fields survive parsing. z.object strips unknown keys, so -// every additive field must be listed below (optional+nullable) or it is -// dropped on restore. -const browserWorkspaceSchema: z.ZodType = z.object({ - id: z.string(), - worktreeId: z.string(), - label: z.string().optional(), - sessionProfileId: z.string().nullable().optional(), - // Why: optional+nullable so pre-field sessions still validate; without this - // zod strips the persisted partition on restore, and an isolated tab whose - // profile mirror is stale at startup would silently fall back to the shared - // default partition — reopening the storage leak (#6923) across restarts. - sessionPartition: z.string().nullable().optional(), - activePageId: z.string().nullable().optional(), - pageIds: z.array(z.string()).optional(), - url: z.string(), - title: z.string(), - loading: z.boolean(), - faviconUrl: z.string().nullable(), - canGoBack: z.boolean(), - canGoForward: z.boolean(), - loadError: browserLoadErrorSchema.nullable(), - createdAt: z.number() -}) - -const browserPageSchema = z.object({ - id: z.string(), - workspaceId: z.string(), - worktreeId: z.string(), - url: z.string(), - title: z.string(), - loading: z.boolean(), - faviconUrl: z.string().nullable(), - canGoBack: z.boolean(), - canGoForward: z.boolean(), - loadError: browserLoadErrorSchema.nullable(), - createdAt: z.number(), - // Why: explicit null marks a browser page as client-local even when its - // worktree is remote-owned; older sessions omit it and keep inferred runtime. - browserRuntimeEnvironmentId: z.string().nullable().optional(), - // Why: optional+nullable so sessions persisted before viewport presets were - // added still validate; without this, zod would strip the field during - // restore and reset the user's chosen preset on every app restart. - viewportPresetId: browserViewportPresetIdSchema.nullable().optional() -}) - -const browserHistoryEntrySchema = z.object({ - url: z.string(), - normalizedUrl: z.string(), - title: z.string(), - lastVisitedAt: z.number(), - visitCount: z.number() -}) - -const browserHistoryEntriesSchema = z - .array(browserHistoryEntrySchema) - .transform((entries) => normalizeBrowserHistoryEntries(entries)) - // ─── Workspace session ────────────────────────────────────────────── +const terminalSurfaceTombstoneSchema = z.object({ + worktreeId: z.string(), + parentTabId: terminalTabIdSchema, + leafId: z.string(), + ptyId: z.string(), + incarnationId: z.string().min(1).max(128), + retiredAt: z.number().finite().nonnegative() +}) + +const worktreeIdSchema = z.string() + export const workspaceSessionStateSchema: z.ZodType = z.object({ - activeRepoId: z.string().nullable(), - activeWorkspaceKey: workspaceKeySchema.nullable().optional(), - activeWorkspaceExecutionHostId: z - .custom( - (value) => typeof value === 'string' && Boolean(parseExecutionHostId(value)) - ) - .nullable() - .optional(), - activeWorktreeId: z.string().nullable(), - activeTabId: z.string().nullable(), - tabsByWorktree: z.record(z.string(), z.array(terminalTabSchema)), - terminalLayoutsByTabId: z.record(terminalTabIdSchema, terminalLayoutSnapshotSchema), - activeWorktreeIdsOnShutdown: z.array(z.string()).optional(), - openFilesByWorktree: z.record(z.string(), z.array(persistedOpenFileSchema)).optional(), - activeFileIdByWorktree: z.record(z.string(), z.string().nullable()).optional(), - markdownFrontmatterVisible: z.record(z.string(), z.boolean()).optional(), - browserTabsByWorktree: z.record(z.string(), z.array(browserWorkspaceSchema)).optional(), - browserPagesByWorkspace: z.record(z.string(), z.array(browserPageSchema)).optional(), - activeBrowserTabIdByWorktree: z.record(z.string(), z.string().nullable()).optional(), - activeTabTypeByWorktree: z.record(z.string(), workspaceVisibleTabTypeSchema).optional(), - browserUrlHistory: browserHistoryEntriesSchema.optional(), - activeTabIdByWorktree: z.record(z.string(), z.string().nullable()).optional(), - unifiedTabs: z.record(z.string(), z.array(tabSchema)).optional(), - tabGroups: z.record(z.string(), z.array(tabGroupSchema)).optional(), - tabGroupLayouts: z.record(z.string(), tabGroupLayoutNodeSchema).optional(), - activeGroupIdByWorktree: z.record(z.string(), z.string()).optional(), - activeConnectionIdsAtShutdown: z.array(z.string()).optional(), - remoteSessionIdsByTabId: z.record(terminalTabIdSchema, z.string()).optional(), - // Why: the sort comparator in order-empty-query-worktrees.ts would produce - // NaN (undefined sort order) if a corrupted session file carried NaN or - // Infinity here. Parse leniently: drop individual bad entries rather than - // failing the entire session. A strict record() rejection here would cause - // parseWorkspaceSession to fall back to defaults for the ENTIRE session - // (terminals, editors, browsers, layouts) on a single corrupted timestamp - // — a blast radius far larger than "Cmd+J falls back to activity recency", - // which is all this field gates. - lastVisitedAtByWorktreeId: z - .preprocess( - (raw) => { - if (raw == null || typeof raw !== 'object') { - return raw - } - const cleaned: Record = {} - for (const [k, v] of Object.entries(raw as Record)) { - if (typeof v === 'number' && Number.isFinite(v) && v >= 0) { - cleaned[k] = v - } - } - return cleaned - }, - z.record(z.string(), z.number().finite().nonnegative()) - ) - .optional(), - defaultTerminalTabsAppliedByWorktreeId: z.record(z.string(), z.literal(true)).optional(), - sleepingAgentSessionsByPaneKey: sleepingAgentSessionsByPaneKeySchema, - terminalPtyIncarnationsByPaneKey: z.record(z.string(), z.string().min(1).max(128)).optional(), - terminalTopologyRevisionByRepoId: z.record(z.string(), z.number().int().nonnegative()).optional(), - terminalSurfaceTombstonesByPaneKey: z - .record( - z.string(), - z.object({ - worktreeId: z.string(), - parentTabId: terminalTabIdSchema, - leafId: z.string(), - ptyId: z.string(), - incarnationId: z.string().min(1).max(128), - retiredAt: z.number().finite().nonnegative() - }) - ) - .optional() + activeRepoId: salvagedField('activeRepoId', z.string().nullable(), () => null), + activeWorkspaceKey: salvagedOptional('activeWorkspaceKey', workspaceKeySchema.nullable()), + activeWorkspaceExecutionHostId: salvagedOptional( + 'activeWorkspaceExecutionHostId', + z + .custom( + (value) => typeof value === 'string' && Boolean(parseExecutionHostId(value)) + ) + .nullable() + ), + activeWorktreeId: salvagedField('activeWorktreeId', z.string().nullable(), () => null), + activeTabId: salvagedField('activeTabId', z.string().nullable(), () => null), + tabsByWorktree: salvagedField( + 'tabsByWorktree', + salvagingRecord(worktreeIdSchema, salvagingArray(terminalTabSchema)), + () => ({}) + ), + terminalLayoutsByTabId: salvagedField( + 'terminalLayoutsByTabId', + salvagingRecord(terminalTabIdSchema, terminalLayoutSnapshotSchema), + () => ({}) + ), + activeWorktreeIdsOnShutdown: salvagedOptional( + 'activeWorktreeIdsOnShutdown', + salvagingArray(worktreeIdSchema) + ), + openFilesByWorktree: salvagedOptional( + 'openFilesByWorktree', + salvagingRecord(worktreeIdSchema, salvagingArray(persistedOpenFileSchema)) + ), + activeFileIdByWorktree: salvagedOptional( + 'activeFileIdByWorktree', + salvagingRecord(worktreeIdSchema, z.string().nullable()) + ), + markdownFrontmatterVisible: salvagedOptional( + 'markdownFrontmatterVisible', + salvagingRecord(z.string(), z.boolean()) + ), + browserTabsByWorktree: salvagedOptional( + 'browserTabsByWorktree', + salvagingRecord(worktreeIdSchema, salvagingArray(browserWorkspaceSchema)) + ), + browserPagesByWorkspace: salvagedOptional( + 'browserPagesByWorkspace', + salvagingRecord(z.string(), salvagingArray(browserPageSchema)) + ), + activeBrowserTabIdByWorktree: salvagedOptional( + 'activeBrowserTabIdByWorktree', + salvagingRecord(worktreeIdSchema, z.string().nullable()) + ), + activeTabTypeByWorktree: salvagedOptional( + 'activeTabTypeByWorktree', + salvagingRecord(worktreeIdSchema, workspaceVisibleTabTypeSchema) + ), + browserUrlHistory: salvagedOptional('browserUrlHistory', browserHistoryEntriesSchema), + activeTabIdByWorktree: salvagedOptional( + 'activeTabIdByWorktree', + salvagingRecord(worktreeIdSchema, z.string().nullable()) + ), + unifiedTabs: salvagedOptional( + 'unifiedTabs', + salvagingRecord(worktreeIdSchema, salvagingArray(tabSchema)) + ), + tabGroups: salvagedOptional( + 'tabGroups', + salvagingRecord(worktreeIdSchema, salvagingArray(tabGroupSchema)) + ), + tabGroupLayouts: salvagedOptional( + 'tabGroupLayouts', + salvagingRecord(worktreeIdSchema, tabGroupLayoutNodeSchema) + ), + activeGroupIdByWorktree: salvagedOptional( + 'activeGroupIdByWorktree', + salvagingRecord(worktreeIdSchema, z.string()) + ), + activeConnectionIdsAtShutdown: salvagedOptional( + 'activeConnectionIdsAtShutdown', + salvagingArray(z.string()) + ), + remoteSessionIdsByTabId: salvagedOptional( + 'remoteSessionIdsByTabId', + salvagingRecord(terminalTabIdSchema, z.string()) + ), + // Why: the sort comparator in order-empty-query-worktrees.ts would produce NaN + // (undefined sort order) from a NaN or Infinity persisted here. + lastVisitedAtByWorktreeId: salvagedOptional( + 'lastVisitedAtByWorktreeId', + salvagingRecord(worktreeIdSchema, z.number().finite().nonnegative()) + ), + defaultTerminalTabsAppliedByWorktreeId: salvagedOptional( + 'defaultTerminalTabsAppliedByWorktreeId', + salvagingRecord(worktreeIdSchema, z.literal(true)) + ), + sleepingAgentSessionsByPaneKey: salvagedOptional( + 'sleepingAgentSessionsByPaneKey', + sleepingAgentSessionsByPaneKeySchema + ), + terminalPtyIncarnationsByPaneKey: salvagedOptional( + 'terminalPtyIncarnationsByPaneKey', + salvagingRecord(z.string(), z.string().min(1).max(128)) + ), + terminalTopologyRevisionByRepoId: salvagedOptional( + 'terminalTopologyRevisionByRepoId', + salvagingRecord(z.string(), z.number().int().nonnegative()) + ), + terminalSurfaceTombstonesByPaneKey: salvagedOptional( + 'terminalSurfaceTombstonesByPaneKey', + salvagingRecord(z.string(), terminalSurfaceTombstoneSchema) + ) }) export type ParsedWorkspaceSession = | { ok: true; value: WorkspaceSessionState } | { ok: false; error: string } +/** Why: keep the error compact — a zod issue dump is noisy and most of the time + * only the first divergent field is actionable for debugging. */ +export function describeWorkspaceSessionError(error: z.ZodError): string { + const firstIssue = error.issues[0] + const path = firstIssue?.path.join('.') || '' + return `${path}: ${firstIssue?.message ?? 'invalid session'}` +} + +export const WORKSPACE_SESSION_UNVALIDATABLE = ': session could not be validated' + +/** safeParse, or null when the validator itself could not run. + * Why: safeParse is documented not to throw, but a payload holding hundreds of + * thousands of bad records overflows the stack while zod materializes an issue + * per field. This parse runs in the Store constructor, so an escaping RangeError + * is a launch failure the user cannot recover from without deleting their + * profile — exactly the "never throw into main" contract at the top of this file. */ +export function safeParseWorkspaceSession( + raw: unknown +): ReturnType | null { + try { + return workspaceSessionStateSchema.safeParse(raw) + } catch { + return null + } +} + /** Validate raw JSON as a WorkspaceSessionState. Returns a discriminated union * so callers can fall back to defaults on failure without a try/catch. */ export function parseWorkspaceSession(raw: unknown): ParsedWorkspaceSession { - const result = workspaceSessionStateSchema.safeParse(raw) + const result = safeParseWorkspaceSession(raw) + if (!result) { + return { ok: false, error: WORKSPACE_SESSION_UNVALIDATABLE } + } if (result.success) { return { ok: true, value: result.data } } - // Why: keep the error compact — a zod issue dump is noisy and most of the - // time only the first divergent field is actionable for debugging. - const firstIssue = result.error.issues[0] - const path = firstIssue?.path.join('.') || '' - return { ok: false, error: `${path}: ${firstIssue?.message ?? 'invalid session'}` } + return { ok: false, error: describeWorkspaceSessionError(result.error) } } diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index eb48de31e4b..4ee620efa82 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -5,6 +5,7 @@ import { RESUMABLE_TUI_AGENTS } from './agent-session-resume' import { isValidTerminalTabId } from './terminal-tab-id' +import { salvagingRecord } from './zod-salvage' const terminalTabIdSchema = z .string() @@ -108,23 +109,8 @@ const sleepingAgentSessionRecordSchema = z { message: 'provider session is not resumable for this agent', path: ['providerSession'] } ) -export const sleepingAgentSessionsByPaneKeySchema = z.preprocess((raw) => { - if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { - return undefined - } - - const cleaned: Record> = Object.create( - null - ) - for (const [paneKey, value] of Object.entries(raw as Record)) { - if (isUnsafeObjectKey(paneKey)) { - continue - } - const parsed = sleepingAgentSessionRecordSchema.safeParse(value) - if (parsed.success && parsed.data.paneKey === paneKey) { - cleaned[paneKey] = parsed.data - } - } - - return Object.keys(cleaned).length > 0 ? { ...cleaned } : undefined -}, z.record(z.string(), sleepingAgentSessionRecordSchema).optional()) +export const sleepingAgentSessionsByPaneKeySchema = salvagingRecord( + z.string().refine((paneKey) => !isUnsafeObjectKey(paneKey)), + sleepingAgentSessionRecordSchema, + (paneKey, record) => record.paneKey === paneKey +).transform((records) => (Object.keys(records).length > 0 ? records : undefined)) diff --git a/src/shared/workspace-session-terminal-schema.test.ts b/src/shared/workspace-session-terminal-schema.test.ts index 367ee953f78..5878b70a1b9 100644 --- a/src/shared/workspace-session-terminal-schema.test.ts +++ b/src/shared/workspace-session-terminal-schema.test.ts @@ -43,7 +43,7 @@ describe('parseWorkspaceSession terminal fields', () => { } }) - it('rejects empty terminal startup cwd values', () => { + it('drops a tab with an empty startup cwd instead of failing the session', () => { const result = parseWorkspaceSession({ activeRepoId: null, activeWorktreeId: 'wt', @@ -67,6 +67,9 @@ describe('parseWorkspaceSession terminal fields', () => { terminalLayoutsByTabId: {} }) - expect(result.ok).toBe(false) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.tabsByWorktree.wt).toEqual([]) + } }) }) diff --git a/src/shared/zod-salvage.ts b/src/shared/zod-salvage.ts new file mode 100644 index 00000000000..64860eab10a --- /dev/null +++ b/src/shared/zod-salvage.ts @@ -0,0 +1,132 @@ +// Zod transforms lack paths, so salvage combinators track bounded diagnostics during parsing. +import { z } from 'zod' + +const MAX_REPORTED_SALVAGE_PATHS = 100 + +type DropCollector = { paths: string[]; count: number } + +let dropCollector: DropCollector | null = null +const dropPath: (string | number)[] = [] + +/** Run a synchronous parse while collecting its salvage count and example paths. */ +export function collectSalvageDrops(parse: () => T): { + value: T + droppedPaths: string[] + droppedCount: number +} { + const previousCollector = dropCollector + const previousPath = [...dropPath] + const collector: DropCollector = { paths: [], count: 0 } + dropCollector = collector + dropPath.length = 0 + try { + const value = parse() + return { value, droppedPaths: collector.paths, droppedCount: collector.count } + } finally { + dropCollector = previousCollector + dropPath.splice(0, dropPath.length, ...previousPath) + } +} + +function reportDrop(segment: string | number): void { + if (!dropCollector) { + return + } + dropCollector.count += 1 + if (dropCollector.paths.length < MAX_REPORTED_SALVAGE_PATHS) { + dropCollector.paths.push([...dropPath, segment].join('.')) + } +} + +function inEntry(segment: string | number, parse: () => T): T { + dropPath.push(segment) + try { + return parse() + } finally { + dropPath.pop() + } +} + +function parseEntry( + schema: T, + raw: unknown +): { success: true; data: z.output } | { success: false } { + try { + const parsed = schema.safeParse(raw) + return parsed.success ? { success: true, data: parsed.data as z.output } : { success: false } + } catch { + return { success: false } + } +} + +/** Array that drops the elements it cannot parse instead of failing. */ +export function salvagingArray(item: T): z.ZodType[], unknown> { + return z.array(z.unknown()).transform((values) => + values.flatMap((value, index) => { + const parsed = inEntry(index, () => parseEntry(item, value)) + if (parsed.success) { + return [parsed.data] + } + reportDrop(index) + return [] + }) + ) +} + +/** Record that drops entries with invalid keys or values instead of failing. */ +export function salvagingRecord, V extends z.ZodType>( + key: K, + value: V, + accepts?: (key: string, value: z.output) => boolean +): z.ZodType>, unknown> { + return z.record(z.string(), z.unknown()).transform((entries) => { + // Why: null prototype so a persisted '__proto__' key cannot poison the result. + const kept: Record> = Object.create(null) + for (const [entryKey, entryValue] of Object.entries(entries)) { + const parsed = parseEntry(key, entryKey).success + ? inEntry(entryKey, () => parseEntry(value, entryValue)) + : null + if (parsed?.success && (!accepts || accepts(entryKey, parsed.data))) { + kept[entryKey] = parsed.data + continue + } + reportDrop(entryKey) + } + return { ...kept } + }) +} + +function salvaged(name: string, schema: z.ZodType, fallback: () => unknown): z.ZodType { + return z.unknown().transform((raw, ctx) => { + if (raw === undefined) { + ctx.addIssue({ code: 'custom', message: 'required', input: raw }) + return z.NEVER + } + const parsed = inEntry(name, () => parseEntry(schema, raw)) + if (parsed.success) { + return parsed.data + } + reportDrop(name) + return fallback() + }) +} + +/** Fall back for an invalid required field; absence stays fatal for foreign payloads. */ +export function salvagedField( + name: string, + schema: T, + fallback: () => z.output +): z.ZodType, unknown> { + return salvaged(name, schema, fallback) as z.ZodType, unknown> +} + +/** Drop an invalid optional field without reporting legitimate absence. */ +export function salvagedOptional( + name: string, + schema: T +): z.ZodType | undefined, unknown> { + return salvaged(name, schema, () => undefined).optional() as z.ZodType< + z.output | undefined, + unknown + > +} diff --git a/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts b/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts new file mode 100644 index 00000000000..50c3d153101 --- /dev/null +++ b/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts @@ -0,0 +1,137 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' +import { test, expect } from './helpers/orca-app' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { ensureTerminalVisible, getActiveWorktreeId, waitForSessionReady } from './helpers/store' +import { TEST_REPO_PATH_FILE } from './global-setup' + +const FIRST_SURVIVOR_TITLE = 'STA-3604 survivor one' +const SECOND_SURVIVOR_TITLE = 'STA-3604 survivor two' +const CORRUPT_TAB_ID = 'sta-3604-corrupt-tab' + +type PersistedData = { + workspaceSession?: { + tabsByWorktree?: Record[]> + unifiedTabs?: Record[]> + } +} + +function persistedDataPath(userDataDir: string): string { + return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') +} + +function injectTruncatedTab(userDataDir: string, worktreeId: string, startupCwd: string): void { + const dataPath = persistedDataPath(userDataDir) + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as PersistedData + const tabs = data.workspaceSession?.tabsByWorktree?.[worktreeId] + if (!tabs) { + throw new Error('Persisted terminal tabs were unavailable for corruption seeding') + } + tabs.push({ + id: CORRUPT_TAB_ID, + ptyId: null, + worktreeId, + title: 'Truncated terminal', + sortOrder: 999, + generation: 3, + startupCwd + }) + writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`) +} + +function persistedSessionEvidence( + userDataDir: string, + worktreeId: string +): { corruptLegacyTabPresent: boolean; unifiedTabIds: string[] } { + const data = JSON.parse(readFileSync(persistedDataPath(userDataDir), 'utf8')) as PersistedData + const legacyTabs = data.workspaceSession?.tabsByWorktree?.[worktreeId] ?? [] + const unifiedTabs = data.workspaceSession?.unifiedTabs?.[worktreeId] ?? [] + return { + corruptLegacyTabPresent: legacyTabs.some((tab) => tab.id === CORRUPT_TAB_ID), + unifiedTabIds: unifiedTabs + .map((tab) => tab.id) + .filter((id): id is string => typeof id === 'string') + } +} + +async function expectSurvivingTabsVisible(page: Page): Promise { + for (const title of [FIRST_SURVIVOR_TITLE, SECOND_SURVIVOR_TITLE]) { + await expect( + page.locator('[data-testid="sortable-tab"]').filter({ hasText: title }) + ).toBeVisible({ timeout: 15_000 }) + } +} + +test('keeps valid terminal tabs visible after a corrupt sibling record on restart', async (// oxlint-disable-next-line no-empty-pattern -- this restart test owns its Electron launches. +{}, testInfo) => { + test.setTimeout(300_000) + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + : '' + test.skip(!repoPath || !existsSync(repoPath), 'Seeded E2E repository is unavailable') + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const first = await session.launch() + firstApp = first.app + const worktreeId = await attachRepoAndOpenTerminal(first.page, repoPath) + await waitForSessionReady(first.page) + await ensureTerminalVisible(first.page) + + const survivorIds = await first.page.evaluate( + ({ worktreeId, firstTitle, secondTitle }) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + const state = store.getState() + const firstTab = state.tabsByWorktree[worktreeId]?.[0] + if (!firstTab) { + throw new Error('Initial terminal tab unavailable') + } + state.setTabCustomTitle(firstTab.id, firstTitle) + const secondTab = state.createTab(worktreeId, undefined, undefined, { activate: false }) + state.setTabCustomTitle(secondTab.id, secondTitle) + return [firstTab.id, secondTab.id] + }, + { worktreeId, firstTitle: FIRST_SURVIVOR_TITLE, secondTitle: SECOND_SURVIVOR_TITLE } + ) + await expectSurvivingTabsVisible(first.page) + + await session.close(firstApp) + firstApp = null + injectTruncatedTab(session.userDataDir, worktreeId, repoPath) + + const second = await session.launch() + secondApp = second.app + await waitForSessionReady(second.page) + await expect.poll(() => getActiveWorktreeId(second.page), { timeout: 15_000 }).toBe(worktreeId) + await ensureTerminalVisible(second.page) + + await expectSurvivingTabsVisible(second.page) + await expect(second.page.locator('.xterm').first()).toBeVisible({ timeout: 15_000 }) + await expect + .poll(() => persistedSessionEvidence(session.userDataDir, worktreeId), { + timeout: 30_000 + }) + .toEqual({ corruptLegacyTabPresent: false, unifiedTabIds: survivorIds }) + + await testInfo.attach('sta-3604-valid-tabs-after-corrupt-session-restart.png', { + body: await second.page.screenshot(), + contentType: 'image/png' + }) + await expectSurvivingTabsVisible(second.page) + } finally { + for (const app of [secondApp, firstApp]) { + if (app) { + await session.close(app).catch(() => {}) + } + } + await session.dispose() + } +}) From 158212b8b3bc6a9bf54c4c7efb94211684d4d7f0 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:11:54 -0700 Subject: [PATCH 026/271] feat(github): add PR comment reactions (#13470) * feat(github): add PR comment reactions * fix(github): harden comment reaction updates --------- Co-authored-by: Jinwoo-H --- .../github/client-pr-local-runtime.test.ts | 18 +- src/main/github/client.test.ts | 108 +++++++++++ src/main/github/client.ts | 73 +++++++- src/main/github/comment-reactions.test.ts | 15 +- src/main/github/comment-reactions.ts | 34 +++- src/main/github/issues.test.ts | 6 +- src/main/github/issues.ts | 2 + src/main/ipc/github.test.ts | 21 +++ src/main/ipc/github.ts | 32 ++++ src/main/runtime/orca-runtime.ts | 21 +++ src/main/runtime/rpc/methods/github.test.ts | 25 +++ src/main/runtime/rpc/methods/github.ts | 19 ++ src/preload/api-types.ts | 10 ++ src/preload/index.ts | 11 ++ .../components/github/CommentReactions.tsx | 168 ++++++++++++++++-- .../components/right-sidebar/ChecksPanel.tsx | 43 +++++ .../right-sidebar/checks-panel-content.tsx | 52 +++++- src/renderer/src/i18n/locales/en.json | 8 +- .../src/lib/pr-comment-reactions.test.ts | 71 ++++++++ src/renderer/src/lib/pr-comment-reactions.ts | 83 +++++++++ src/renderer/src/store/slices/github.test.ts | 149 ++++++++++++++++ src/renderer/src/store/slices/github.ts | 119 +++++++++++++ src/renderer/src/web/web-preload-api.test.ts | 12 ++ src/renderer/src/web/web-preload-api.ts | 8 + src/shared/types.ts | 3 + .../helpers/pr-comments-sidebar-fixture.ts | 8 +- .../helpers/source-control-ai-generation.ts | 20 ++- tests/e2e/pr-comments-sidebar-cards.spec.ts | 33 ++++ 28 files changed, 1120 insertions(+), 52 deletions(-) create mode 100644 src/renderer/src/lib/pr-comment-reactions.test.ts create mode 100644 src/renderer/src/lib/pr-comment-reactions.ts diff --git a/src/main/github/client-pr-local-runtime.test.ts b/src/main/github/client-pr-local-runtime.test.ts index a2aa0595c74..0e7d4013d2e 100644 --- a/src/main/github/client-pr-local-runtime.test.ts +++ b/src/main/github/client-pr-local-runtime.test.ts @@ -225,10 +225,14 @@ describe('GitHub PR local runtime routing', () => { return { stdout: '[]' } } if (endpoint.endsWith('/pulls/7/comments/11/replies')) { - return { stdout: JSON.stringify({ id: 12, user: null, body: 'Reply' }) } + return { + stdout: JSON.stringify({ id: 12, node_id: 'PRRC_reply_12', user: null, body: 'Reply' }) + } } if (endpoint.endsWith('/pulls/7/comments')) { - return { stdout: JSON.stringify({ id: 13, user: null, body: 'Inline' }) } + return { + stdout: JSON.stringify({ id: 13, node_id: 'PRRC_inline_13', user: null, body: 'Inline' }) + } } return { stdout: '', stderr: '' } }) @@ -250,7 +254,10 @@ describe('GitHub PR local runtime routing', () => { prRepo, localGitOptions ) - ).resolves.toMatchObject({ ok: true }) + ).resolves.toMatchObject({ + ok: true, + comment: { reactionSubjectId: 'PRRC_reply_12' } + }) await expect( addPRReviewComment({ repoPath: '/repo-root', @@ -263,7 +270,10 @@ describe('GitHub PR local runtime routing', () => { path: 'src/app.ts', line: 10 }) - ).resolves.toMatchObject({ ok: true }) + ).resolves.toMatchObject({ + ok: true, + comment: { reactionSubjectId: 'PRRC_inline_13' } + }) await expect( updatePRTitle('/repo-root', 7, 'New title', null, prRepo, localGitOptions) ).resolves.toBe(true) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 4b5f1375aa8..eef3aace0e7 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -178,6 +178,7 @@ import { getPullRequestPushTarget, mergePR, resolveReviewThread, + setPRCommentReaction, setPRAutoMerge, updatePRState, updatePRTitle, @@ -4082,6 +4083,113 @@ describe('GitHub GraphQL rate-limit guard', () => { ) }) + it('returns GraphQL reaction subjects and viewer state for PR comments', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + comments: { + nodes: [ + { + id: 'IC_1', + databaseId: 10, + author: { login: 'octo', avatarUrl: '', __typename: 'User' }, + body: 'Issue comment', + createdAt: '2026-04-01T00:00:00Z', + url: 'https://example.test/issue-comment', + reactionGroups: [ + { + content: 'THUMBS_UP', + viewerHasReacted: true, + reactors: { totalCount: 2 } + } + ] + } + ] + }, + reviewThreads: { + nodes: [ + { + id: 'PRRT_1', + isResolved: false, + line: 4, + startLine: null, + originalLine: 4, + originalStartLine: null, + comments: { + nodes: [ + { + id: 'PRRC_1', + databaseId: 11, + author: { login: 'reviewer', avatarUrl: '', __typename: 'User' }, + body: 'Inline comment', + createdAt: '2026-04-01T00:01:00Z', + url: 'https://example.test/review-comment', + path: 'src/app.ts', + reactionGroups: [ + { + content: 'EYES', + viewerHasReacted: false, + reactors: { totalCount: 1 } + } + ] + } + ] + } + } + ] + } + } + } + } + }) + }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) + + const comments = await getPRComments('/repo-root', 7, { + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + }) + + expect(comments).toEqual([ + expect.objectContaining({ + id: 10, + reactionSubjectId: 'IC_1', + reactions: [{ content: '+1', count: 2, viewerHasReacted: true }] + }), + expect.objectContaining({ + id: 11, + reactionSubjectId: 'PRRC_1', + reactions: [{ content: 'eyes', count: 1, viewerHasReacted: false }] + }) + ]) + }) + + it('adds and removes PR comment reactions through GraphQL', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '{}', stderr: '' }) + + await expect(setPRCommentReaction('/repo-root', 'IC_1', '+1', true)).resolves.toBe(true) + await expect(setPRCommentReaction('/repo-root', 'PRRC_1', 'eyes', false)).resolves.toBe(true) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining(['subjectId=IC_1', 'content=THUMBS_UP']), + expect.any(Object) + ) + expect(ghExecFileAsyncMock.mock.calls[0]?.[0].join(' ')).toContain('addReaction') + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining(['subjectId=PRRC_1', 'content=EYES']), + expect.any(Object) + ) + expect(ghExecFileAsyncMock.mock.calls[1]?.[0].join(' ')).toContain('removeReaction') + expect(noteRateLimitSpendMock).toHaveBeenCalledTimes(2) + }) + it('uses explicit PR repo for merge and title mutations', async () => { ghExecFileAsyncMock .mockResolvedValueOnce({ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 07472e5c096..46ea1180e24 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -12,6 +12,7 @@ import type { PRCheckDetail, PRCheckRunDetails, GitHubCommentResult, + GitHubReactionContent, GitHubPRReviewCommentInput, PRComment, GitHubViewer, @@ -110,7 +111,11 @@ import { mapPRState, deriveCheckStatus } from './mappers' -import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from './comment-reactions' +import { + mapGraphQLReactionGroups, + toGraphQLReactionContent, + type GitHubGraphQLReactionGroup +} from './comment-reactions' import { getRateLimit, noteRepositoryRateLimitSpend, @@ -4117,6 +4122,7 @@ query($owner: String!, $repo: String!, $pr: Int!) { originalStartLine comments(first: 100) { nodes { + id databaseId author { __typename login avatarUrl(size: 48) } body @@ -4125,6 +4131,7 @@ query($owner: String!, $repo: String!, $pr: Int!) { path reactionGroups { content + viewerHasReacted reactors { totalCount } @@ -4135,6 +4142,7 @@ query($owner: String!, $repo: String!, $pr: Int!) { } comments(first: 100) { nodes { + id databaseId author { __typename login avatarUrl(size: 48) } body @@ -4142,6 +4150,7 @@ query($owner: String!, $repo: String!, $pr: Int!) { url reactionGroups { content + viewerHasReacted reactors { totalCount } @@ -4255,6 +4264,7 @@ export async function getPRComments( originalStartLine: number | null comments: { nodes: { + id: string databaseId: number author: { __typename?: string; login: string; avatarUrl: string } | null body: string @@ -4266,6 +4276,7 @@ export async function getPRComments( } } type GQLIssueComment = { + id: string databaseId: number author: { __typename?: string; login: string; avatarUrl: string } | null body: string @@ -4295,6 +4306,7 @@ export async function getPRComments( createdAt: c.createdAt, url: c.url, isBot: c.author?.__typename === 'Bot', + reactionSubjectId: c.id, reactions: mapGraphQLReactionGroups(c.reactionGroups) }) ) @@ -4313,6 +4325,7 @@ export async function getPRComments( createdAt: c.createdAt, url: c.url, isBot: c.author?.__typename === 'Bot', + reactionSubjectId: c.id, reactions: mapGraphQLReactionGroups(c.reactionGroups), path: c.path, threadId: thread.id, @@ -4393,6 +4406,62 @@ export async function getPRComments( } } +export async function setPRCommentReaction( + repoPath: string, + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean, + connectionId?: string | null, + prRepo?: GitHubApiRepository | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const mutation = reacted ? 'addReaction' : 'removeReaction' + const query = `mutation($subjectId: ID!, $content: ReactionContent!) { + ${mutation}(input: { subjectId: $subjectId, content: $content }) { + subject { id } + } + }` + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return false + } + const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) + if (guard.blocked) { + console.warn( + `${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})` + ) + return false + } + await acquire() + try { + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) + await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${query}`, + '-f', + `subjectId=${reactionSubjectId}`, + '-f', + `content=${toGraphQLReactionContent(content)}` + ], + ghOptions + ) + return true + } catch (err) { + console.warn(`${mutation} failed:`, err) + return false + } finally { + release() + } +} + /** * Mark or unmark a PR file as viewed via GitHub's GraphQL API. */ @@ -4492,6 +4561,7 @@ export async function resolveReviewThread( function mapReviewCommentResponse( data: { id?: number + node_id?: string | null user: { login: string; avatar_url: string; type?: string } | null body?: string created_at?: string @@ -4507,6 +4577,7 @@ function mapReviewCommentResponse( ): PRComment { return { id: data.id ?? Date.now(), + reactionSubjectId: data.node_id?.trim() || undefined, author: data.user?.login ?? 'You', authorAvatarUrl: data.user?.avatar_url ?? '', body: data.body ?? body, diff --git a/src/main/github/comment-reactions.test.ts b/src/main/github/comment-reactions.test.ts index 4478214076f..4c0366fda3f 100644 --- a/src/main/github/comment-reactions.test.ts +++ b/src/main/github/comment-reactions.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it } from 'vitest' -import { mapGraphQLReactionGroups } from './comment-reactions' +import { mapGraphQLReactionGroups, toGraphQLReactionContent } from './comment-reactions' describe('mapGraphQLReactionGroups', () => { it('normalizes GraphQL reaction groups into GitHub comment reactions', () => { expect( mapGraphQLReactionGroups([ - { content: 'EYES', reactors: { totalCount: 1 } }, - { content: 'THUMBS_UP', reactors: { totalCount: 2 } }, + { content: 'EYES', reactors: { totalCount: 1 }, viewerHasReacted: false }, + { content: 'THUMBS_UP', reactors: { totalCount: 2 }, viewerHasReacted: true }, { content: 'HEART', reactors: { totalCount: 0 } }, { content: 'UNKNOWN', reactors: { totalCount: 9 } } ]) ).toEqual([ - { content: '+1', count: 2 }, - { content: 'eyes', count: 1 } + { content: '+1', count: 2, viewerHasReacted: true }, + { content: 'eyes', count: 1, viewerHasReacted: false } ]) }) @@ -22,4 +22,9 @@ describe('mapGraphQLReactionGroups', () => { undefined ) }) + + it('maps REST reaction content to the GraphQL enum', () => { + expect(toGraphQLReactionContent('+1')).toBe('THUMBS_UP') + expect(toGraphQLReactionContent('hooray')).toBe('HOORAY') + }) }) diff --git a/src/main/github/comment-reactions.ts b/src/main/github/comment-reactions.ts index d93ae551c26..1bb831f30ca 100644 --- a/src/main/github/comment-reactions.ts +++ b/src/main/github/comment-reactions.ts @@ -13,6 +13,7 @@ type GitHubGraphQLReactionContent = export type GitHubGraphQLReactionGroup = { content?: string | null reactors?: { totalCount?: number | null } | null + viewerHasReacted?: boolean | null } const GRAPHQL_REACTION_CONTENT: Record = { @@ -37,10 +38,27 @@ const REACTION_ORDER: GitHubReactionContent[] = [ 'eyes' ] +const REACTION_CONTENT_TO_GRAPHQL: Record = { + '+1': 'THUMBS_UP', + '-1': 'THUMBS_DOWN', + laugh: 'LAUGH', + confused: 'CONFUSED', + heart: 'HEART', + hooray: 'HOORAY', + rocket: 'ROCKET', + eyes: 'EYES' +} + +export function toGraphQLReactionContent( + content: GitHubReactionContent +): GitHubGraphQLReactionContent { + return REACTION_CONTENT_TO_GRAPHQL[content] +} + export function mapGraphQLReactionGroups( groups?: GitHubGraphQLReactionGroup[] | null ): GitHubReaction[] | undefined { - const counts = new Map() + const reactionsByContent = new Map() for (const group of groups ?? []) { const content = group.content && group.content in GRAPHQL_REACTION_CONTENT @@ -50,12 +68,20 @@ export function mapGraphQLReactionGroups( if (!content || count <= 0) { continue } - counts.set(content, (counts.get(content) ?? 0) + count) + const existing = reactionsByContent.get(content) + const viewerHasReacted = Boolean(existing?.viewerHasReacted || group.viewerHasReacted) + reactionsByContent.set(content, { + content, + count: (existing?.count ?? 0) + count, + ...(existing?.viewerHasReacted !== undefined || group.viewerHasReacted != null + ? { viewerHasReacted } + : {}) + }) } const reactions = REACTION_ORDER.flatMap((content) => { - const count = counts.get(content) ?? 0 - return count > 0 ? [{ content, count }] : [] + const reaction = reactionsByContent.get(content) + return reaction ? [reaction] : [] }) return reactions.length > 0 ? reactions : undefined } diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index e9b8a10282c..23a7242810c 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -195,6 +195,7 @@ describe('issue source operations', () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ id: 9, + node_id: 'IC_enterprise_9', user: { login: 'octo', avatar_url: '', type: 'User' }, body: 'Enterprise comment', created_at: '2026-07-16T00:00:00.000Z', @@ -208,7 +209,10 @@ describe('issue source operations', () => { repo: 'orca', host: 'github.acme-corp.com' }) - ).resolves.toMatchObject({ ok: true }) + ).resolves.toMatchObject({ + ok: true, + comment: { reactionSubjectId: 'IC_enterprise_9' } + }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 3ffa6dc662a..e2a4dd52677 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -473,6 +473,7 @@ export async function addIssueComment( ) const data = JSON.parse(stdout) as { id?: number + node_id?: string | null user: { login: string; avatar_url: string; type?: string } | null body?: string created_at?: string @@ -483,6 +484,7 @@ export async function addIssueComment( } const comment: PRComment = { id: data.id, + reactionSubjectId: data.node_id?.trim() || undefined, author: data.user?.login ?? 'You', authorAvatarUrl: data.user?.avatar_url ?? '', body: data.body ?? body, diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index bbd26f84d6a..c137c399480 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -22,6 +22,7 @@ const { getPRChecksMock, getPRCheckDetailsMock, getPRCommentsMock, + setPRCommentReactionMock, resolveReviewThreadMock, setPRFileViewedMock, addPRReviewCommentMock, @@ -64,6 +65,7 @@ const { getPRChecksMock: vi.fn(), getPRCheckDetailsMock: vi.fn(), getPRCommentsMock: vi.fn(), + setPRCommentReactionMock: vi.fn(), resolveReviewThreadMock: vi.fn(), setPRFileViewedMock: vi.fn(), addPRReviewCommentMock: vi.fn(), @@ -123,6 +125,7 @@ vi.mock('../github/client', () => ({ getPRChecks: getPRChecksMock, getPRCheckDetails: getPRCheckDetailsMock, getPRComments: getPRCommentsMock, + setPRCommentReaction: setPRCommentReactionMock, resolveReviewThread: resolveReviewThreadMock, setPRFileViewed: setPRFileViewedMock, addPRReviewComment: addPRReviewCommentMock, @@ -212,6 +215,7 @@ describe('registerGitHubHandlers', () => { getPRChecksMock.mockReset() getPRCheckDetailsMock.mockReset() getPRCommentsMock.mockReset() + setPRCommentReactionMock.mockReset() resolveReviewThreadMock.mockReset() setPRFileViewedMock.mockReset() addPRReviewCommentMock.mockReset() @@ -907,6 +911,7 @@ describe('registerGitHubHandlers', () => { getPRChecksMock.mockResolvedValue([]) getPRCheckDetailsMock.mockResolvedValue(null) getPRCommentsMock.mockResolvedValue([]) + setPRCommentReactionMock.mockResolvedValue(true) resolveReviewThreadMock.mockResolvedValue(true) setPRFileViewedMock.mockResolvedValue(true) addPRReviewCommentReplyMock.mockResolvedValue({ ok: true }) @@ -964,6 +969,13 @@ describe('registerGitHubHandlers', () => { prRepo, noCache: true }) + await handlers['gh:setPRCommentReaction'](null, { + repoPath: '/workspace/repo', + reactionSubjectId: ' IC_1 ', + content: '+1', + reacted: true, + prRepo + }) await handlers['gh:resolveReviewThread'](null, { repoPath: '/workspace/repo', threadId: 'thread-1', @@ -1124,6 +1136,15 @@ describe('registerGitHubHandlers', () => { null, localGitOptions ) + expect(setPRCommentReactionMock).toHaveBeenCalledWith( + '/workspace/repo', + 'IC_1', + '+1', + true, + null, + prRepo, + localGitOptions + ) expect(resolveReviewThreadMock).toHaveBeenCalledWith( '/workspace/repo', 'thread-1', diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 6fcc53bab71..91cfc94f061 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -13,6 +13,7 @@ import type { GitHubPRRefreshCandidate, GitHubPRRefreshEnqueueResult, GitHubPRRefreshReason, + GitHubReactionContent, PRRefreshOutcome, GitHubPRFile } from '../../shared/types' @@ -39,6 +40,7 @@ import { getPRChecks, getPRCheckDetails, getPRComments, + setPRCommentReaction, resolveReviewThread, setPRFileViewed, addPRReviewComment, @@ -667,6 +669,36 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) + ipcMain.handle( + 'gh:setPRCommentReaction', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredRepo(args, store) + if (!args.reactionSubjectId?.trim()) { + return false + } + return setPRCommentReaction( + repo.path, + args.reactionSubjectId.trim(), + args.content, + args.reacted, + repoConnectionId(repo), + args.prRepo ?? null, + ...localGitOptionArgs(store, repo) + ) + } + ) + ipcMain.handle( 'gh:resolveReviewThread', async ( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 526acea823b..33de03a4a09 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -228,6 +228,7 @@ import type { GitHubPullRequestStateUpdate, GitHubPRFile, GitHubPRReviewCommentInput, + GitHubReactionContent, GitLabIssueUpdate, GitLabMRInlineCommentInput, GitLabProjectRef, @@ -591,6 +592,7 @@ import { getPRCheckDetails, rerunPRChecks, getPRComments, + setPRCommentReaction, getIssue, resolveReviewThread, setPRFileViewed, @@ -20147,6 +20149,25 @@ export class OrcaRuntimeService { ) } + async setRepoPRCommentReaction( + repoSelector: string, + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean, + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return setPRCommentReaction( + repo.path, + reactionSubjectId, + content, + reacted, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + async getRepoPRFileContents( repoSelector: string, args: { diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 06cc04d917e..0a283b52072 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -219,6 +219,31 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: [] }) }) + it('sets a PR comment reaction on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + setRepoPRCommentReaction: vi.fn().mockResolvedValue(true) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.setPRCommentReaction', { + repo: 'repo-1', + reactionSubjectId: 'IC_1', + content: 'heart', + reacted: true, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } + }) + ) + + expect(runtime.setRepoPRCommentReaction).toHaveBeenCalledWith('repo-1', 'IC_1', 'heart', true, { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + }) + expect(response).toMatchObject({ ok: true, result: true }) + }) + it('fetches PR file contents 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 858d22b72bb..ed715e4e0e8 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -73,6 +73,13 @@ const PullRequest = RepoSelector.extend({ prRepo: SlugRepo.nullable().optional() }) +const PRCommentReaction = RepoSelector.extend({ + reactionSubjectId: requiredString('Missing reaction subject ID'), + content: z.enum(['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']), + reacted: z.boolean(), + prRepo: SlugRepo.nullable().optional() +}) + const PullRequestChecks = PullRequest.extend({ headSha: OptionalString }) @@ -459,6 +466,18 @@ export const GITHUB_METHODS: RpcMethod[] = [ noCache: params.noCache }) }), + defineMethod({ + name: 'github.setPRCommentReaction', + params: PRCommentReaction, + handler: async (params, { runtime }) => + runtime.setRepoPRCommentReaction( + params.repo, + params.reactionSubjectId, + params.content, + params.reacted, + params.prRepo ?? null + ) + }), defineMethod({ name: 'github.prFileContents', params: PullRequestFileContents, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index bc6abd8cc03..87c8fce5901 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -226,6 +226,7 @@ import type { MarkdownDocument, FloatingTerminalCwdRequest, GitHubIssueUpdate, + GitHubReactionContent, GitHubPRRefreshCandidate, GitHubPRRefreshEnqueueResult, GitHubPRRefreshEvent, @@ -1882,6 +1883,15 @@ export type PreloadApi = { prRepo?: GitHubOwnerRepo | null noCache?: boolean }) => Promise + setPRCommentReaction: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + }) => Promise resolveReviewThread: (args: { repoPath: string repoId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 8b192819496..2cf88323b23 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -75,6 +75,7 @@ import type { GitHubCommentResult, GitHubCreateIssueResult, GitHubOwnerRepo, + GitHubReactionContent, GitHubWorkItem, JiraProjectStatusOrder, GitPushTarget, @@ -1470,6 +1471,16 @@ const api = { noCache?: boolean }): Promise => ipcRenderer.invoke('gh:prComments', args), + setPRCommentReaction: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:setPRCommentReaction', args), + resolveReviewThread: (args: { repoPath: string repoId?: string diff --git a/src/renderer/src/components/github/CommentReactions.tsx b/src/renderer/src/components/github/CommentReactions.tsx index 9c362b6520d..bbdca0fbe3d 100644 --- a/src/renderer/src/components/github/CommentReactions.tsx +++ b/src/renderer/src/components/github/CommentReactions.tsx @@ -1,6 +1,12 @@ import React from 'react' +import { SmilePlus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' -import type { GitHubReaction } from '../../../../shared/types' +import { cn } from '@/lib/utils' +import { GITHUB_REACTION_ORDER } from '@/lib/pr-comment-reactions' +import type { GitHubReaction, GitHubReactionContent } from '../../../../shared/types' const REACTION_EMOJI: Record = { '+1': '👍', @@ -13,36 +19,160 @@ const REACTION_EMOJI: Record = { eyes: '👀' } +const REACTION_LABEL: Record = { + '+1': 'thumbs up', + '-1': 'thumbs down', + laugh: 'laugh', + confused: 'confused', + heart: 'heart', + hooray: 'hooray', + rocket: 'rocket', + eyes: 'eyes' +} + export function CommentReactions({ - reactions + reactions, + className, + onReactionChange }: { reactions?: GitHubReaction[] + className?: string + onReactionChange?: (content: GitHubReactionContent, reacted: boolean) => Promise | void }): React.JSX.Element | null { const visibleReactions = (reactions ?? []).filter((reaction) => reaction.count > 0) - if (visibleReactions.length === 0) { + const [open, setOpen] = React.useState(false) + const [pendingContent, setPendingContent] = React.useState(null) + if (visibleReactions.length === 0 && !onReactionChange) { return null } + const changeReaction = async ( + content: GitHubReactionContent, + reacted: boolean + ): Promise => { + if (!onReactionChange || pendingContent) { + return + } + setPendingContent(content) + setOpen(false) + try { + await onReactionChange(content, reacted) + } finally { + setPendingContent(null) + } + } + + const pickerLabel = translate( + 'auto.components.github.CommentReactions.addReaction', + 'Add reaction' + ) + return ( -
+
{visibleReactions.map((reaction) => ( - + {onReactionChange ? ( + + ) : ( + + + {reaction.count} + )} - > - - {reaction.count} - + ))} + {onReactionChange ? ( + !pendingContent && setOpen(nextOpen)}> + + + + + + + + {pickerLabel} + + + +
+ {GITHUB_REACTION_ORDER.map((content) => { + const reaction = reactions?.find((candidate) => candidate.content === content) + const reacted = Boolean(reaction?.viewerHasReacted) + return ( + + ) + })} +
+
+
+ ) : null}
) } diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 028abac821c..805a125d565 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -25,6 +25,7 @@ import { useActiveWorktree, useRepoById } from '@/store/selectors' import { useChecksPanelTerminalWorktree } from './use-checks-panel-terminal-worktree' import { cn } from '@/lib/utils' import { openHttpLink } from '@/lib/http-link-routing' +import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment-reactions' import { Button } from '@/components/ui/button' import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' import { @@ -64,6 +65,7 @@ import type { PRCheckDetail, PRCheckRunDetails, PRComment, + GitHubReactionContent, PRRefreshErrorType } from '../../../../shared/types' import { getConnectionId } from '@/lib/connection-context' @@ -496,6 +498,7 @@ export default function ChecksPanel(): React.JSX.Element { const fetchPRComments = useAppStore((s) => s.fetchPRComments) const addPRConversationComment = useAppStore((s) => s.addPRConversationComment) const addPRReviewCommentReply = useAppStore((s) => s.addPRReviewCommentReply) + const setPRCommentReaction = useAppStore((s) => s.setPRCommentReaction) const resolveReviewThread = useAppStore((s) => s.resolveReviewThread) const detectedAgentIds = useAppStore((s) => s.detectedAgentIds) const remoteDetectedAgentIds = useAppStore((s) => { @@ -2941,6 +2944,45 @@ export default function ChecksPanel(): React.JSX.Element { [pr?.prRepo, confirm] ) + const handleSetReaction = useCallback( + async (comment: PRComment, content: GitHubReactionContent, reacted: boolean): Promise => { + const reactionSubjectId = comment.reactionSubjectId + if (!repo || !prNumber || !pr?.prRepo || !reactionSubjectId) { + return + } + const requestKey = checksPanelAsyncResultKey( + prCacheKey, + branch, + prNumber, + pr.prRepo, + pr.headSha + ) + const previousReaction = comment.reactions?.find((reaction) => reaction.content === content) + setComments((current) => setReactionOnSubject(current, reactionSubjectId, content, reacted)) + const ok = await setPRCommentReaction( + repo.path, + prNumber, + reactionSubjectId, + content, + reacted, + { repoId: repo.id, prRepo: pr.prRepo } + ) + if (!isCurrentAsyncResult(requestKey) || ok) { + return + } + setComments((current) => + restoreReactionOnSubject(current, reactionSubjectId, content, previousReaction) + ) + toast.error( + translate( + 'auto.components.right.sidebar.ChecksPanel.updateReactionFailed', + 'Failed to update reaction.' + ) + ) + }, + [branch, isCurrentAsyncResult, pr, prCacheKey, prNumber, repo, setPRCommentReaction] + ) + const handleReplyToComment = useCallback( async (comment: PRComment, body: string, options: { notifyOnFailure?: boolean } = {}) => { const notifyOnFailure = options.notifyOnFailure !== false @@ -4506,6 +4548,7 @@ export default function ChecksPanel(): React.JSX.Element { onResolve={pr || activeGitLabReview ? handleResolve : undefined} onEditComment={pr ? handleEditComment : undefined} onDeleteComment={pr ? handleDeleteComment : undefined} + onSetReaction={canTargetPRComments ? handleSetReaction : undefined} /> void onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise + onSetReaction?: ( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean + ) => Promise onQueueForAgent?: () => void }): React.JSX.Element { const automated = isBotPRComment(comment, botAuthorOverrides) @@ -2007,13 +2015,17 @@ function CommentRow({
) : ( - +
+ + onSetReaction(comment, content, reacted) + : undefined + } + /> +
)}
@@ -2036,6 +2048,7 @@ function PRCommentGroupView({ onReply, onEditComment, onDeleteComment, + onSetReaction, onQueueForAgent }: { group: PRCommentGroup @@ -2053,6 +2066,11 @@ function PRCommentGroupView({ onReply?: (comment: PRComment, body: string) => Promise onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise + onSetReaction?: ( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean + ) => Promise onQueueForAgent?: () => void }): React.JSX.Element { // Reply targets a specific comment id so any comment in a thread — root or @@ -2099,6 +2117,7 @@ function PRCommentGroupView({ onResolve, onEditComment, onDeleteComment, + onSetReaction, onQueueForAgent } @@ -2181,7 +2200,8 @@ function ResolvedCommentGroupsSection({ onCancelReply, onReply, onEditComment, - onDeleteComment + onDeleteComment, + onSetReaction }: { groups: PRCommentGroup[] botAuthorOverrides: ReadonlySet @@ -2195,6 +2215,11 @@ function ResolvedCommentGroupsSection({ onReply?: (comment: PRComment, body: string) => Promise onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise + onSetReaction?: ( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean + ) => Promise }): React.JSX.Element | null { if (groups.length === 0) { return null @@ -2230,6 +2255,7 @@ function ResolvedCommentGroupsSection({ onReply={onReply} onEditComment={onEditComment} onDeleteComment={onDeleteComment} + onSetReaction={onSetReaction} /> ))} @@ -2296,7 +2322,8 @@ export function PRCommentsList({ onReply, onResolve, onEditComment, - onDeleteComment + onDeleteComment, + onSetReaction }: { comments: PRComment[] commentsLoading: boolean @@ -2313,6 +2340,11 @@ export function PRCommentsList({ onResolve?: (threadId: string, resolve: boolean) => boolean | Promise onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise + onSetReaction?: ( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean + ) => Promise }): React.JSX.Element { const presentation = React.useMemo(() => getPRCommentPresentationClasses(), []) const [commentFilter, setCommentFilter] = useState('all') @@ -2435,6 +2467,7 @@ export function PRCommentsList({ onReply={onReply} onEditComment={onEditComment} onDeleteComment={onDeleteComment} + onSetReaction={onSetReaction} onQueueForAgent={canQueue ? () => addGroupToSelection(groupId) : undefined} /> ) @@ -2765,6 +2798,7 @@ export function PRCommentsList({ onReply={onReply} onEditComment={onEditComment} onDeleteComment={onDeleteComment} + onSetReaction={onSetReaction} /> )} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3184344d835..baadf663b56 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2412,6 +2412,11 @@ "label": "Close as duplicate", "description": "Duplicate of another issue" } + }, + "CommentReactions": { + "addReaction": "Add reaction", + "removeNamedReaction": "Remove {{value0}} reaction", + "addNamedReaction": "Add {{value0}} reaction" } }, "linear": { @@ -10566,7 +10571,8 @@ "pending": "Syncing…", "branch": "Sync Branch" }, - "7e4b2a19c0": "Could not resolve the GitHub PR to reply on." + "7e4b2a19c0": "Could not resolve the GitHub PR to reply on.", + "updateReactionFailed": "Failed to update reaction." }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancel", diff --git a/src/renderer/src/lib/pr-comment-reactions.test.ts b/src/renderer/src/lib/pr-comment-reactions.test.ts new file mode 100644 index 00000000000..d742fde85d8 --- /dev/null +++ b/src/renderer/src/lib/pr-comment-reactions.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { PRComment } from '../../../shared/types' +import { + restoreCommentReaction, + setCommentReaction, + setReactionOnSubject +} from './pr-comment-reactions' + +function comment(overrides: Partial = {}): PRComment { + return { + id: 1, + author: 'octocat', + authorAvatarUrl: '', + body: 'Looks good', + createdAt: '2026-08-09T12:00:00Z', + url: 'https://github.com/acme/orca/pull/1#issuecomment-1', + ...overrides + } +} + +describe('setCommentReaction', () => { + it('adds a viewer reaction in GitHub order', () => { + expect( + setCommentReaction( + comment({ reactions: [{ content: 'heart', count: 2, viewerHasReacted: false }] }), + '+1', + true + ).reactions + ).toEqual([ + { content: '+1', count: 1, viewerHasReacted: true }, + { content: 'heart', count: 2, viewerHasReacted: false } + ]) + }) + + it('removes the final viewer reaction', () => { + expect( + setCommentReaction( + comment({ reactions: [{ content: 'rocket', count: 1, viewerHasReacted: true }] }), + 'rocket', + false + ).reactions + ).toBeUndefined() + }) + + it('updates only the targeted comment', () => { + const target = comment({ reactionSubjectId: 'IC_1' }) + const other = comment({ id: 2, reactionSubjectId: 'IC_2' }) + const result = setReactionOnSubject([target, other], 'IC_1', 'eyes', true) + + expect(result[0].reactions).toEqual([{ content: 'eyes', count: 1, viewerHasReacted: true }]) + expect(result[1]).toBe(other) + }) + + it('restores only the failed reaction while preserving concurrent updates', () => { + const result = restoreCommentReaction( + comment({ + reactions: [ + { content: 'heart', count: 3, viewerHasReacted: true }, + { content: 'eyes', count: 1, viewerHasReacted: false } + ] + }), + 'heart', + { content: 'heart', count: 2, viewerHasReacted: false } + ) + + expect(result.reactions).toEqual([ + { content: 'heart', count: 2, viewerHasReacted: false }, + { content: 'eyes', count: 1, viewerHasReacted: false } + ]) + }) +}) diff --git a/src/renderer/src/lib/pr-comment-reactions.ts b/src/renderer/src/lib/pr-comment-reactions.ts new file mode 100644 index 00000000000..f124f082d17 --- /dev/null +++ b/src/renderer/src/lib/pr-comment-reactions.ts @@ -0,0 +1,83 @@ +import type { GitHubReaction, GitHubReactionContent, PRComment } from '../../../shared/types' + +export const GITHUB_REACTION_ORDER: readonly GitHubReactionContent[] = [ + '+1', + '-1', + 'laugh', + 'confused', + 'heart', + 'hooray', + 'rocket', + 'eyes' +] + +function sortReactions(reactions: GitHubReaction[]): GitHubReaction[] { + return reactions.sort( + (left, right) => + GITHUB_REACTION_ORDER.indexOf(left.content) - GITHUB_REACTION_ORDER.indexOf(right.content) + ) +} + +export function setCommentReaction( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean +): PRComment { + const reactions = comment.reactions ?? [] + const current = reactions.find((reaction) => reaction.content === content) + if (Boolean(current?.viewerHasReacted) === reacted) { + return comment + } + + const nextCount = Math.max(0, (current?.count ?? 0) + (reacted ? 1 : -1)) + const nextReaction: GitHubReaction = { + content, + count: nextCount, + viewerHasReacted: reacted + } + const nextReactions = sortReactions( + reactions + .filter((reaction) => reaction.content !== content) + .concat(nextCount > 0 ? nextReaction : []) + ) + + return { ...comment, reactions: nextReactions.length > 0 ? nextReactions : undefined } +} + +export function restoreCommentReaction( + comment: PRComment, + content: GitHubReactionContent, + previousReaction?: GitHubReaction +): PRComment { + const reactions = sortReactions([ + ...(comment.reactions ?? []).filter((reaction) => reaction.content !== content), + ...(previousReaction ? [previousReaction] : []) + ]) + return { ...comment, reactions: reactions.length > 0 ? reactions : undefined } +} + +export function setReactionOnSubject( + comments: readonly PRComment[], + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean +): PRComment[] { + return comments.map((comment) => + comment.reactionSubjectId === reactionSubjectId + ? setCommentReaction(comment, content, reacted) + : comment + ) +} + +export function restoreReactionOnSubject( + comments: readonly PRComment[], + reactionSubjectId: string, + content: GitHubReactionContent, + previousReaction?: GitHubReaction +): PRComment[] { + return comments.map((comment) => + comment.reactionSubjectId === reactionSubjectId + ? restoreCommentReaction(comment, content, previousReaction) + : comment + ) +} diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index c3947d58c28..cadbc5acb8f 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -45,6 +45,7 @@ const mockApi = { prComments: vi.fn().mockResolvedValue([]), addIssueComment: vi.fn(), addPRReviewCommentReply: vi.fn(), + setPRCommentReaction: vi.fn(), resolveReviewThread: vi.fn(), listWorkItems: vi.fn(), countWorkItems: vi.fn().mockResolvedValue(0), @@ -1503,6 +1504,7 @@ describe('createGitHubSlice PR comment mutations', () => { url: '' } }) + mockApi.gh.setPRCommentReaction.mockResolvedValue(true) }) it('deduplicates merged PR comments and preserves existing thread metadata', () => { @@ -1666,6 +1668,153 @@ describe('createGitHubSlice PR comment mutations', () => { ).toMatchObject({ body: 'reply', threadId: 'PRRT_1', path: 'src/a.ts', line: 8 }) }) + it('updates cached reactions through local GitHub IPC', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-id' + const cacheKey = `${repoId}::pr-comments::acme/widgets::12` + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + commentsCache: { + [cacheKey]: { + data: [ + { + id: 10, + reactionSubjectId: 'IC_10', + author: 'reviewer', + authorAvatarUrl: '', + body: 'Nice', + createdAt: '2026-03-28T00:00:00Z', + url: '' + } + ], + fetchedAt: 1 + } + } + } as unknown as Partial) + + await store.getState().setPRCommentReaction(repoPath, 12, 'IC_10', 'heart', true, { + repoId, + prRepo: { owner: 'Acme', repo: 'Widgets' } + }) + + expect(mockApi.gh.setPRCommentReaction).toHaveBeenCalledWith({ + repoPath, + repoId, + reactionSubjectId: 'IC_10', + content: 'heart', + reacted: true, + prRepo: { owner: 'Acme', repo: 'Widgets' }, + sourceContext: undefined + }) + expect(store.getState().commentsCache[cacheKey]?.data?.[0].reactions).toEqual([ + { content: 'heart', count: 1, viewerHasReacted: true } + ]) + }) + + it('rolls back an optimistic reaction when GitHub rejects it', async () => { + let resolveReaction!: (ok: boolean) => void + mockApi.gh.setPRCommentReaction.mockImplementationOnce( + () => new Promise((resolve) => (resolveReaction = resolve)) + ) + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-id' + const cacheKey = `${repoId}::pr-comments::acme/widgets::12` + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + commentsCache: { + [cacheKey]: { + data: [ + { + id: 10, + reactionSubjectId: 'IC_10', + author: 'reviewer', + authorAvatarUrl: '', + body: 'Nice', + createdAt: '2026-03-28T00:00:00Z', + url: '', + reactions: [{ content: 'heart', count: 2, viewerHasReacted: false }] + } + ], + fetchedAt: 1 + } + } + } as unknown as Partial) + + const pending = store.getState().setPRCommentReaction(repoPath, 12, 'IC_10', 'heart', true, { + repoId, + prRepo: { owner: 'Acme', repo: 'Widgets' } + }) + const optimisticEntry = store.getState().commentsCache[cacheKey] + store.setState({ + commentsCache: { + ...store.getState().commentsCache, + [cacheKey]: { + ...optimisticEntry, + data: (optimisticEntry?.data ?? []).map((comment) => ({ + ...comment, + reactions: [ + ...(comment.reactions ?? []), + { content: 'eyes' as const, count: 1, viewerHasReacted: false } + ] + })) + } + } + }) + resolveReaction(false) + const ok = await pending + + expect(ok).toBe(false) + expect(store.getState().commentsCache[cacheKey]?.data?.[0].reactions).toEqual([ + { content: 'heart', count: 2, viewerHasReacted: false }, + { content: 'eyes', count: 1, viewerHasReacted: false } + ]) + }) + + it('routes reaction mutations through the workspace runtime', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-pr-reaction', + ok: true, + result: true, + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-id' + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos: [ + { + id: repoId, + path: repoPath, + name: 'repo', + kind: 'git', + executionHostId: 'runtime:env-1' + } + ] + } as unknown as Partial) + + await store.getState().setPRCommentReaction(repoPath, 12, 'PRRC_10', 'rocket', true, { + repoId, + prRepo: { owner: 'Acme', repo: 'Widgets' } + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.setPRCommentReaction', + params: { + repo: repoId, + reactionSubjectId: 'PRRC_10', + content: 'rocket', + reacted: true, + prRepo: { owner: 'Acme', repo: 'Widgets' } + }, + timeoutMs: 30_000 + }) + expect(mockApi.gh.setPRCommentReaction).not.toHaveBeenCalled() + }) + it('does not mutate the PR comments cache when GitHub omits the comment payload', async () => { mockApi.gh.addIssueComment.mockResolvedValueOnce({ ok: true }) const store = createTestStore() diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 599ef6cd960..21b66f2e9bd 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -17,6 +17,7 @@ import type { PRRefreshErrorType, PRRefreshOutcome, GitHubCommentResult, + GitHubReactionContent, IssueInfo, PRCheckDetail, PRCheckRunDetails, @@ -74,6 +75,7 @@ import { type TaskSourceContext } from '../../../../shared/task-source-context' import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github-pr-for-branch-outcome' +import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment-reactions' // ─── ProjectV2 cache types ──────────────────────────────────────────── // Why: separate from CacheEntry — project-view has a single GraphQL source (no issue/PR fallback) and a distinct error union. @@ -1890,6 +1892,14 @@ export type GitHubSlice = { line?: number } ) => Promise + setPRCommentReaction: ( + repoPath: string, + prNumber: number, + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean, + options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } + ) => Promise resolveReviewThread: ( repoPath: string, prNumber: number, @@ -3839,6 +3849,115 @@ export const createGitHubSlice: StateCreator = (s return { ok: true, comment } }, + setPRCommentReaction: async ( + repoPath, + prNumber, + reactionSubjectId, + content, + reacted, + options + ) => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(prNumber, options?.prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + const previousComment = get().commentsCache[cacheKey]?.data?.find( + (comment) => comment.reactionSubjectId === reactionSubjectId + ) + const previousReaction = previousComment?.reactions?.find( + (reaction) => reaction.content === content + ) + set((state) => { + const entry = state.commentsCache[cacheKey] + if (!entry?.data) { + return state + } + return { + commentsCache: { + ...state.commentsCache, + [cacheKey]: { + ...entry, + data: setReactionOnSubject(entry.data, reactionSubjectId, content, reacted) + } + } + } + }) + + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + let ok = false + try { + ok = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.setPRCommentReaction', + { + repo: requestContext.target.runtimeRepoId, + reactionSubjectId, + content, + reacted, + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.setPRCommentReaction({ + repoPath, + repoId, + reactionSubjectId, + content, + reacted, + prRepo: options?.prRepo ?? null, + sourceContext: options?.sourceContext + }) + } catch (err) { + console.error('Failed to update PR comment reaction:', err) + } + if (!ok && previousComment) { + set((state) => { + const entry = state.commentsCache[cacheKey] + if (!entry?.data) { + return state + } + return { + commentsCache: { + ...state.commentsCache, + [cacheKey]: { + ...entry, + data: restoreReactionOnSubject( + entry.data, + reactionSubjectId, + content, + previousReaction + ) + } + } + } + }) + } + return ok + }, + resolveReviewThread: async (repoPath, prNumber, threadId, resolve, options) => { const repo = get().repos?.find((candidate) => options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 92dd209d047..cc508040e49 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -3769,6 +3769,7 @@ describe('web GitHub preload API', () => { 'resolveProjectRef', 'resolveReviewThread', 'setPRAutoMerge', + 'setPRCommentReaction', 'setPRFileViewed', 'starOrca', 'updateIssue', @@ -3950,6 +3951,17 @@ describe('web GitHub preload API', () => { expectedMethod: 'github.prComments', expectedParams: withRepo({ repoPath, prNumber: 7, noCache: true }) }, + { + key: 'setPRCommentReaction', + args: { repoPath, reactionSubjectId: 'IC_1', content: 'heart', reacted: true }, + expectedMethod: 'github.setPRCommentReaction', + expectedParams: withRepo({ + repoPath, + reactionSubjectId: 'IC_1', + content: 'heart', + reacted: true + }) + }, { key: 'resolveReviewThread', args: { repoPath, threadId: 'thread-1', resolve: true }, diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 6f049d237c8..038e05e1373 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -289,6 +289,7 @@ type WebGitHubRouteKey = | 'prCheckDetails' | 'rerunPRChecks' | 'prComments' + | 'setPRCommentReaction' | 'resolveReviewThread' | 'setPRFileViewed' | 'updatePRTitle' @@ -337,6 +338,7 @@ type WebGitHubRuntimeMethod = | 'github.prCheckDetails' | 'github.rerunPRChecks' | 'github.prComments' + | 'github.setPRCommentReaction' | 'github.resolveReviewThread' | 'github.setPRFileViewed' | 'github.updatePRTitle' @@ -438,6 +440,7 @@ export const GITHUB_WEB_RPC_METHODS = { prCheckDetails: 'github.prCheckDetails', rerunPRChecks: 'github.rerunPRChecks', prComments: 'github.prComments', + setPRCommentReaction: 'github.setPRCommentReaction', resolveReviewThread: 'github.resolveReviewThread', setPRFileViewed: 'github.setPRFileViewed', updatePRTitle: 'github.updatePRTitle', @@ -2385,6 +2388,11 @@ function createGitHubApi(): WebGitHubApi { route>(GITHUB_WEB_RPC_METHODS.rerunPRChecks, args), prComments: (args) => route>(GITHUB_WEB_RPC_METHODS.prComments, args), + setPRCommentReaction: (args) => + route>( + GITHUB_WEB_RPC_METHODS.setPRCommentReaction, + args + ), resolveReviewThread: (args) => route>( GITHUB_WEB_RPC_METHODS.resolveReviewThread, diff --git a/src/shared/types.ts b/src/shared/types.ts index c6dfff01d3c..9eb3de46c3b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1485,6 +1485,7 @@ export type GitHubReactionContent = export type GitHubReaction = { content: GitHubReactionContent count: number + viewerHasReacted?: boolean } export type PRComment = { @@ -1495,6 +1496,8 @@ export type PRComment = { createdAt: string url: string reactions?: GitHubReaction[] + /** GraphQL node ID for GitHub comments that support reaction mutations. */ + reactionSubjectId?: string /** File path for inline review comments (absent for top-level conversation comments). */ path?: string /** GraphQL node ID of the review thread — present only for inline review comments. diff --git a/tests/e2e/helpers/pr-comments-sidebar-fixture.ts b/tests/e2e/helpers/pr-comments-sidebar-fixture.ts index c6784a2f282..b44aaa84c3c 100644 --- a/tests/e2e/helpers/pr-comments-sidebar-fixture.ts +++ b/tests/e2e/helpers/pr-comments-sidebar-fixture.ts @@ -15,6 +15,7 @@ export const FIXTURE_COMMENTS: PRComment[] = [ body: 'Please update this handler before merge.', createdAt: '2026-05-14T10:00:00.000Z', url: 'https://github.com/acme/orca/pull/73#discussion_r101', + reactionSubjectId: 'PRRC_101', threadId: 'thread-open', path: 'src/handler.ts', isResolved: false @@ -25,7 +26,8 @@ export const FIXTURE_COMMENTS: PRComment[] = [ authorAvatarUrl: '', body: 'LGTM on the overall approach.', createdAt: '2026-05-14T11:00:00.000Z', - url: 'https://github.com/acme/orca/pull/73#issuecomment-102' + url: 'https://github.com/acme/orca/pull/73#issuecomment-102', + reactionSubjectId: 'IC_102' }, { id: 103, @@ -72,7 +74,8 @@ export async function seedPRCommentsSidebarFixture(page: Page): Promise [], fetchPRComments: async () => comments, + setPRCommentReaction: async () => true, fetchUpstreamStatus: async () => undefined, setUpstreamStatus: () => undefined })) diff --git a/tests/e2e/helpers/source-control-ai-generation.ts b/tests/e2e/helpers/source-control-ai-generation.ts index c408c6c4a6a..149f6a019d3 100644 --- a/tests/e2e/helpers/source-control-ai-generation.ts +++ b/tests/e2e/helpers/source-control-ai-generation.ts @@ -31,22 +31,30 @@ export async function openChecks(page: Page, worktreeId: string): Promise const state = window.__store?.getState() state?.setActiveWorktree(targetWorktreeId) state?.setRightSidebarOpen(true) - state?.setRightSidebarTab('checks') }, worktreeId) await expect .poll( () => page.evaluate((targetWorktreeId) => { const state = window.__store?.getState() - return ( - state?.activeWorktreeId === targetWorktreeId && - state.rightSidebarOpen && - state.rightSidebarTab === 'checks' - ) + return state?.activeWorktreeId === targetWorktreeId && state.rightSidebarOpen }, worktreeId), { timeout: 5_000 } ) .toBe(true) + const checksButton = page.getByRole('button', { name: 'Checks', exact: true }) + await expect + .poll( + async () => { + if ((await page.evaluate(() => window.__store?.getState().rightSidebarTab)) !== 'checks') { + await checksButton.click() + } + await page.waitForTimeout(250) + return page.evaluate(() => window.__store?.getState().rightSidebarTab) + }, + { timeout: 10_000 } + ) + .toBe('checks') } export async function seedCreatePrComposer(page: Page): Promise<{ diff --git a/tests/e2e/pr-comments-sidebar-cards.spec.ts b/tests/e2e/pr-comments-sidebar-cards.spec.ts index 9cccce81bfb..ee95b3f74cb 100644 --- a/tests/e2e/pr-comments-sidebar-cards.spec.ts +++ b/tests/e2e/pr-comments-sidebar-cards.spec.ts @@ -124,6 +124,39 @@ test.describe('PR comments sidebar cards view', () => { expect(positions[1]).toBeLessThan(positions[2]) }) + test('adds reactions to conversation and review-thread comments', async ({ orcaPage }) => { + const { worktreeId } = await seedPRCommentsSidebarFixture(orcaPage) + await openChecks(orcaPage, worktreeId) + await expect(orcaPage.getByText('Needs review · 1')).toBeVisible({ timeout: 10_000 }) + + const conversationCard = orcaPage.getByTestId('pr-comment-group').filter({ + hasText: 'LGTM on the overall approach.' + }) + const conversationReactionButton = conversationCard.getByRole('button', { + name: 'Add reaction' + }) + await conversationReactionButton.evaluate((element) => (element as HTMLElement).click()) + const heartReactionButton = orcaPage.getByRole('button', { name: 'Add heart reaction' }) + await expect(heartReactionButton).toBeVisible() + await heartReactionButton.evaluate((element) => (element as HTMLElement).click()) + await expect( + conversationCard.getByRole('button', { name: '1 heart reaction' }) + ).toHaveAttribute('aria-pressed', 'true') + await expect(orcaPage.getByRole('button', { name: 'Add rocket reaction' })).toHaveCount(0) + + const reviewThreadCard = orcaPage.getByTestId('pr-comment-group').filter({ + hasText: 'Please update this handler before merge.' + }) + const threadReactionButton = reviewThreadCard.getByRole('button', { name: 'Add reaction' }) + await threadReactionButton.evaluate((element) => (element as HTMLElement).click()) + await orcaPage + .getByRole('button', { name: 'Add rocket reaction' }) + .evaluate((element) => (element as HTMLElement).click()) + await expect( + reviewThreadCard.getByRole('button', { name: '1 rocket reaction' }) + ).toHaveAttribute('aria-pressed', 'true') + }) + test('queues an open thread for the agent from the visible row action and menu fallback', async ({ orcaPage }) => { From f2b2ece83184070f2d6017e9f4dea4c37ca0044f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:24:09 -0700 Subject: [PATCH 027/271] perf(renderer): reuse locale collators (#13444) --- .../locale-collator-sort-benchmark.mjs | 147 ++++++++++++++++++ src/renderer/src/components/TaskPage.tsx | 3 +- .../src/components/jira-issue-sorter.ts | 3 +- .../native-chat/native-chat-picker-items.ts | 5 +- .../src/lib/browser-palette-search.ts | 3 +- .../src/lib/locale-text-collators.test.ts | 77 +++++++++ src/renderer/src/lib/locale-text-collators.ts | 13 ++ .../src/lib/simulator-palette-search.ts | 3 +- .../src/lib/workspace-tab-palette-results.ts | 3 +- 9 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 config/scripts/locale-collator-sort-benchmark.mjs create mode 100644 src/renderer/src/lib/locale-text-collators.test.ts create mode 100644 src/renderer/src/lib/locale-text-collators.ts diff --git a/config/scripts/locale-collator-sort-benchmark.mjs b/config/scripts/locale-collator-sort-benchmark.mjs new file mode 100644 index 00000000000..8a68331bd44 --- /dev/null +++ b/config/scripts/locale-collator-sort-benchmark.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +// Benchmarks optioned localeCompare calls used by renderer list comparators. +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' +import { createJiti } from 'jiti' + +const ROUND_COUNT = 5 +const MIN_ROUND_MS = 120 +const jiti = createJiti(import.meta.url, { + alias: { '@': fileURLToPath(new URL('../../src/renderer/src', import.meta.url)) } +}) +const { compareBaseSensitivityLocaleText } = await jiti.import( + '../../src/renderer/src/lib/locale-text-collators.ts' +) +const { sortJiraIssues } = await jiti.import( + '../../src/renderer/src/components/jira-issue-sorter.ts' +) + +let randomState = 0x9e3779b9 +function random() { + randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0 + return randomState / 0x100000000 +} + +function shuffle(values) { + for (let index = values.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)) + ;[values[index], values[swapIndex]] = [values[swapIndex], values[index]] + } + return values +} + +const labels = ['alpha', 'Álpha', 'beta', 'BÉTA', 'café', 'Cafe', 'zeta', 'Ångström'] + +function makeJiraIssues(count) { + return shuffle( + Array.from({ length: count }, (_, index) => ({ + key: `TASK-${(index * 37) % (count + 17)}`, + title: labels[index % labels.length], + updatedAt: '2026-01-01T00:00:00.000Z' + })) + ) +} + +function makeBaseSensitivityValues(count) { + return shuffle( + Array.from( + { length: count }, + (_, index) => + `${labels[(index * 5) % labels.length]}-${String((index * 41) % (count + 23)).padStart(4, '0')}` + ) + ) +} + +function calibrate(run) { + for (let index = 0; index < 5; index += 1) { + run() + } + let iterations = 1 + while (true) { + const startedAt = performance.now() + for (let index = 0; index < iterations; index += 1) { + run() + } + if (performance.now() - startedAt >= MIN_ROUND_MS) { + break + } + iterations *= 2 + } + return iterations +} + +function measureRound(run, iterations) { + const startedAt = performance.now() + for (let index = 0; index < iterations; index += 1) { + run() + } + return (performance.now() - startedAt) / iterations +} + +function measurePair(before, after) { + const beforeIterations = calibrate(before) + const afterIterations = calibrate(after) + const beforeSamples = [] + const afterSamples = [] + for (let round = 0; round < ROUND_COUNT; round += 1) { + if (round % 2 === 0) { + beforeSamples.push(measureRound(before, beforeIterations)) + afterSamples.push(measureRound(after, afterIterations)) + } else { + afterSamples.push(measureRound(after, afterIterations)) + beforeSamples.push(measureRound(before, beforeIterations)) + } + } + const middle = Math.floor(ROUND_COUNT / 2) + return { + beforeMs: beforeSamples.sort((a, b) => a - b)[middle], + afterMs: afterSamples.sort((a, b) => a - b)[middle] + } +} + +function assertSameOrder(before, after, label) { + const expected = before() + const actual = after() + if ( + expected.length !== actual.length || + expected.some((value, index) => value !== actual[index]) + ) { + throw new Error(`${label} sort order changed`) + } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('Renderer locale sort, ms per sort (median of 5 rounds). Lower is better.') +console.log( + `${pad('mode', 9)} ${pad('items', 7)} ${pad('per-call', 11)} ${pad('reused', 11)} ${pad('speedup', 9)}` +) + +for (const count of [36, 50, 250]) { + const issues = makeJiraIssues(count) + const before = () => + [...issues] + .sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true })) + .map((issue) => issue.key) + const after = () => sortJiraIssues(issues, 'key', 'asc').map((issue) => issue.key) + assertSameOrder(before, after, `numeric ${count}`) + const { beforeMs, afterMs } = measurePair(before, after) + console.log( + `${pad('numeric', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} + +for (const count of [10, 50, 250]) { + const values = makeBaseSensitivityValues(count) + const before = () => + [...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + const after = () => [...values].sort(compareBaseSensitivityLocaleText) + assertSameOrder(before, after, `base ${count}`) + const { beforeMs, afterMs } = measurePair(before, after) + console.log( + `${pad('base', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} + +console.log( + '\n36 rows matches the Linear page size, 50 matches the picker/Jira scale, and\n250 is a stress case. Both arms assert identical output before timing.' +) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 6b86b16a000..520f2debbdd 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -43,6 +43,7 @@ import { useAllWorktrees, useRepoMap } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { compareNumericLocaleText } from '@/lib/locale-text-collators' import { getSettingsFocusedExecutionHostId, parseExecutionHostId, @@ -846,7 +847,7 @@ function compareLinearIssues(a: LinearIssue, b: LinearIssue, orderBy: LinearOrde return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() } if (orderBy === 'identifier') { - return a.identifier.localeCompare(b.identifier, undefined, { numeric: true }) + return compareNumericLocaleText(a.identifier, b.identifier) } const priorityDelta = getLinearPriorityRank(a.priority) - getLinearPriorityRank(b.priority) diff --git a/src/renderer/src/components/jira-issue-sorter.ts b/src/renderer/src/components/jira-issue-sorter.ts index a95680c1c34..875aaff0ef0 100644 --- a/src/renderer/src/components/jira-issue-sorter.ts +++ b/src/renderer/src/components/jira-issue-sorter.ts @@ -1,4 +1,5 @@ import type { JiraIssue, JiraPriority } from '../../../shared/types' +import { compareNumericLocaleText } from '@/lib/locale-text-collators' export type JiraIssueSortColumn = 'key' | 'title' | 'status' | 'priority' | 'assignee' | 'updated' @@ -57,7 +58,7 @@ export function sortJiraIssues( return [...issues].sort((a, b) => { let comparison = 0 if (orderBy === 'key') { - comparison = a.key.localeCompare(b.key, undefined, { numeric: true }) + comparison = compareNumericLocaleText(a.key, b.key) } else if (orderBy === 'title') { comparison = a.title.localeCompare(b.title) } else if (orderBy === 'status') { diff --git a/src/renderer/src/components/native-chat/native-chat-picker-items.ts b/src/renderer/src/components/native-chat/native-chat-picker-items.ts index 2592ca0acab..6938c2078a4 100644 --- a/src/renderer/src/components/native-chat/native-chat-picker-items.ts +++ b/src/renderer/src/components/native-chat/native-chat-picker-items.ts @@ -4,6 +4,7 @@ import { isSafeDisplayCharacter, stripUnsafeDisplayCharacters } from '../../../../shared/skill-display-text' +import { compareBaseSensitivityLocaleText } from '@/lib/locale-text-collators' // Send classification lives in shared so mobile gates optimistic echoes with // the same rules; re-exported here to keep renderer import paths stable. @@ -191,7 +192,7 @@ function sanitizePickerText(value: string, maxLength: number): string { function compareDiscoveredSkills(a: DiscoveredSkill, b: DiscoveredSkill): number { return ( SCOPE_PRIORITY[a.sourceKind] - SCOPE_PRIORITY[b.sourceKind] || - a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || + compareBaseSensitivityLocaleText(a.name, b.name) || a.skillFilePath.localeCompare(b.skillFilePath) ) } @@ -202,7 +203,7 @@ function comparePickerSkills( ): number { return ( SCOPE_PRIORITY[a.sources[0].sourceKind] - SCOPE_PRIORITY[b.sources[0].sourceKind] || - a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + compareBaseSensitivityLocaleText(a.name, b.name) ) } diff --git a/src/renderer/src/lib/browser-palette-search.ts b/src/renderer/src/lib/browser-palette-search.ts index 94233164037..7c76e9f9ead 100644 --- a/src/renderer/src/lib/browser-palette-search.ts +++ b/src/renderer/src/lib/browser-palette-search.ts @@ -1,6 +1,7 @@ import { ORCA_BROWSER_BLANK_URL } from '../../../shared/constants' import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' +import { compareBaseSensitivityLocaleText } from './locale-text-collators' import { resolveWorktreeDisplayName } from './worktree-default-display-name' import type { MatchRange } from './worktree-palette-search' @@ -43,7 +44,7 @@ export function isBrowserPaletteQueryTooLarge( } function compareText(a: string, b: string): number { - return a.localeCompare(b, undefined, { sensitivity: 'base' }) + return compareBaseSensitivityLocaleText(a, b) } export function isBlankBrowserUrl(url: string): boolean { diff --git a/src/renderer/src/lib/locale-text-collators.test.ts b/src/renderer/src/lib/locale-text-collators.test.ts new file mode 100644 index 00000000000..63a15ba5b63 --- /dev/null +++ b/src/renderer/src/lib/locale-text-collators.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest' +import { compareBaseSensitivityLocaleText, compareNumericLocaleText } from './locale-text-collators' + +function expectComparatorParity( + values: readonly string[], + legacy: (a: string, b: string) => number, + current: (a: string, b: string) => number +): void { + for (const a of values) { + for (const b of values) { + expect(Math.sign(current(a, b))).toBe(Math.sign(legacy(a, b))) + } + } + expect([...values].sort(current)).toEqual([...values].sort(legacy)) +} + +describe('locale text collators', () => { + it('preserves base-sensitivity localeCompare ordering', () => { + const values = [ + 'zeta', + 'Zeta', + 'Álpha', + 'alpha', + 'BÉTA', + 'beta', + 'café', + 'Cafe', + 'a-b', + 'a b', + 'a_b', + 'Ångström', + 'alpha' + ] + const legacy = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: 'base' }) + + expectComparatorParity(values, legacy, compareBaseSensitivityLocaleText) + }) + + it('preserves numeric localeCompare ordering and ties', () => { + const values = [ + 'TASK-10', + 'TASK-2', + 'TASK-02', + 'TASK-1', + 'TASK-20', + 'TASK-11', + 'TASK_2', + 'task-2', + 'TASK-2' + ] + const legacy = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true }) + + expectComparatorParity(values, legacy, compareNumericLocaleText) + }) + + it('constructs each collator only when its comparison mode is first used', async () => { + vi.resetModules() + const NativeCollator = Intl.Collator + const collatorSpy = vi + .spyOn(Intl, 'Collator') + .mockImplementation(function Collator(locales, options) { + return new NativeCollator(locales, options) + }) + try { + const comparison = await import('./locale-text-collators') + expect(collatorSpy).not.toHaveBeenCalled() + comparison.compareNumericLocaleText('TASK-2', 'TASK-10') + comparison.compareNumericLocaleText('TASK-3', 'TASK-11') + expect(collatorSpy).toHaveBeenCalledTimes(1) + comparison.compareBaseSensitivityLocaleText('café', 'Cafe') + comparison.compareBaseSensitivityLocaleText('Álpha', 'alpha') + expect(collatorSpy).toHaveBeenCalledTimes(2) + } finally { + collatorSpy.mockRestore() + } + }) +}) diff --git a/src/renderer/src/lib/locale-text-collators.ts b/src/renderer/src/lib/locale-text-collators.ts new file mode 100644 index 00000000000..d8e7bccda12 --- /dev/null +++ b/src/renderer/src/lib/locale-text-collators.ts @@ -0,0 +1,13 @@ +let baseSensitivityCollator: Intl.Collator | undefined +let numericCollator: Intl.Collator | undefined + +export function compareBaseSensitivityLocaleText(a: string, b: string): number { + // Why: stay lazy like localeCompare while resolving ICU options only once. + baseSensitivityCollator ??= new Intl.Collator(undefined, { sensitivity: 'base' }) + return baseSensitivityCollator.compare(a, b) +} + +export function compareNumericLocaleText(a: string, b: string): number { + numericCollator ??= new Intl.Collator(undefined, { numeric: true }) + return numericCollator.compare(a, b) +} diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index d1c7b47779b..148d723f8f2 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -2,6 +2,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import type { Tab, TabGroup, Worktree } from '../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' import { selectPaletteTypeAliasMatch } from './palette-type-alias-match' +import { compareBaseSensitivityLocaleText } from './locale-text-collators' import { resolveWorktreeDisplayName } from './worktree-default-display-name' import type { MatchRange } from './worktree-palette-search' @@ -67,7 +68,7 @@ export type BuildSearchableSimulatorTabsOptions = { } function compareText(a: string, b: string): number { - return a.localeCompare(b, undefined, { sensitivity: 'base' }) + return compareBaseSensitivityLocaleText(a, b) } function findRange(text: string, query: string): MatchRange | null { diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts index 18570802abc..6a34cfbd95a 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.ts @@ -1,4 +1,5 @@ import { selectPaletteTypeAliasMatch } from './palette-type-alias-match' +import { compareBaseSensitivityLocaleText } from './locale-text-collators' import { resolveWorktreeDisplayName } from './worktree-default-display-name' import type { MatchRange } from './worktree-palette-search' import type { @@ -27,7 +28,7 @@ export type WorkspaceTabPaletteSearchResult = { } function compareText(a: string, b: string): number { - return a.localeCompare(b, undefined, { sensitivity: 'base' }) + return compareBaseSensitivityLocaleText(a, b) } function findRange(text: string, query: string): MatchRange | null { From 8859e73980d2cf81eaad465b258bbb13d54aab8e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:24:33 -0700 Subject: [PATCH 028/271] perf(main): retire stale worktree marker probes (#13437) --- ...worktree-base-pending-marker-benchmark.mjs | 117 ++++++++++++++++++ .../worktree-base-directory-poller.test.ts | 61 +++++++++ .../ipc/worktree-base-directory-poller.ts | 43 ++++--- 3 files changed, 206 insertions(+), 15 deletions(-) create mode 100644 config/scripts/worktree-base-pending-marker-benchmark.mjs diff --git a/config/scripts/worktree-base-pending-marker-benchmark.mjs b/config/scripts/worktree-base-pending-marker-benchmark.mjs new file mode 100644 index 00000000000..9240acd22ad --- /dev/null +++ b/config/scripts/worktree-base-pending-marker-benchmark.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +// Run with: node config/scripts/worktree-base-pending-marker-benchmark.mjs +import fs from 'node:fs' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { syncBuiltinESMExports } from 'node:module' +import os from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { createJiti } from 'jiti' + +const CANDIDATE_COUNT = Number(process.env.ORCA_PENDING_MARKER_BENCH_CANDIDATES ?? '64') +const TOTAL_TICKS = Number(process.env.ORCA_PENDING_MARKER_BENCH_TICKS ?? '900') +const STEADY_TICKS = Number(process.env.ORCA_PENDING_MARKER_BENCH_STEADY_TICKS ?? '300') + +for (const [name, value] of [ + ['ORCA_PENDING_MARKER_BENCH_CANDIDATES', CANDIDATE_COUNT], + ['ORCA_PENDING_MARKER_BENCH_TICKS', TOTAL_TICKS], + ['ORCA_PENDING_MARKER_BENCH_STEADY_TICKS', STEADY_TICKS] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} +if (STEADY_TICKS >= TOTAL_TICKS) { + throw new Error('ORCA_PENDING_MARKER_BENCH_STEADY_TICKS must be smaller than total ticks') +} + +const root = await mkdtemp(join(os.tmpdir(), 'orca-pending-marker-bench-')) +const markerPaths = new Set() +for (let index = 0; index < CANDIDATE_COUNT; index += 1) { + const candidate = join(root, `ordinary-folder-${index}`) + await mkdir(candidate) + markerPaths.add(join(candidate, '.git')) +} + +const originalStat = fs.promises.stat +let visibleTick = 0 +const markerStatTicks = [] +fs.promises.stat = async (path, ...args) => { + if (markerPaths.has(String(path))) { + markerStatTicks.push(visibleTick) + } + return originalStat(path, ...args) +} +syncBuiltinESMExports() + +let poller +let timeout +try { + const jiti = createJiti(import.meta.url) + const { startWorktreeBaseDirectoryPoller } = await jiti.import( + '../../src/main/ipc/worktree-base-directory-poller.ts' + ) + const repo = { repoId: 'repo-1', repoName: 'repo', nestWorkspaces: false } + const target = { + key: `base:local:${root}`, + kind: 'base', + path: root, + repos: new Map([[repo.repoId, repo]]) + } + let finish + let fail + const completed = new Promise((resolve, reject) => { + finish = resolve + fail = reject + }) + timeout = setTimeout(() => fail(new Error('poller benchmark timed out')), 30_000) + const startedAt = performance.now() + poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + () => {}, + { + pollIntervalMs: 0, + visibility: { + isWindowVisible: () => { + visibleTick += 1 + if (visibleTick > TOTAL_TICKS) { + finish() + return false + } + return true + }, + onWindowBecameVisible: () => () => {} + } + } + ) + await completed + clearTimeout(timeout) + await poller.unsubscribe() + poller = undefined + + const steadyStartTick = TOTAL_TICKS - STEADY_TICKS + const steadyMarkerStats = markerStatTicks.filter((tick) => tick > steadyStartTick).length + const totalMarkerStats = markerStatTicks.length + const statsPerCandidateTick = steadyMarkerStats / CANDIDATE_COUNT / STEADY_TICKS + const elapsedMs = performance.now() - startedAt + + console.log('Worktree-base marker cooldown benchmark. Lower is better.') + console.log( + JSON.stringify({ + candidates: CANDIDATE_COUNT, + totalTicks: TOTAL_TICKS, + steadyTicks: STEADY_TICKS, + totalMarkerStats, + steadyMarkerStats, + statsPerCandidateTick: Number(statsPerCandidateTick.toFixed(4)), + elapsedMs: Number(elapsedMs.toFixed(1)) + }) + ) +} finally { + clearTimeout(timeout) + await poller?.unsubscribe() + fs.promises.stat = originalStat + syncBuiltinESMExports() + await rm(root, { recursive: true, force: true }) +} diff --git a/src/main/ipc/worktree-base-directory-poller.test.ts b/src/main/ipc/worktree-base-directory-poller.test.ts index 09cda0ed480..e28823adbc8 100644 --- a/src/main/ipc/worktree-base-directory-poller.test.ts +++ b/src/main/ipc/worktree-base-directory-poller.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { writeFileSync } from 'node:fs' import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createWorktreePollerWindowVisibility, startWorktreeBaseDirectoryPoller, + WORKTREE_BASE_BACKSTOP_TICKS, type WorktreeBasePollEvent, type WorktreePollerWindowVisibility } from './worktree-base-directory-poller' @@ -204,6 +206,65 @@ describe('worktree base directory poller', () => { expect(fullScans.length).toBeGreaterThan(0) }) + it('retires stale marker probes while preserving backstop detection and path reuse', async () => { + const root = await makeRoot() + const ordinaryFolder = join(root, 'ordinary-folder') + const markerPath = join(ordinaryFolder, '.git') + await mkdir(ordinaryFolder) + + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('base', root) + let fullScans = 0 + let pendingMarkerProbes = 0 + let writeMarkerOnNextProbe = false + const pendingMarkerMaxTicks = WORKTREE_BASE_BACKSTOP_TICKS * 2 + const markerCreationTick = pendingMarkerMaxTicks * 2 + WORKTREE_BASE_BACKSTOP_TICKS * 4 + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { + pollIntervalMs: 0, + pendingMarkerMaxTicks, + onFullScan: () => { + fullScans += 1 + if (fullScans * WORKTREE_BASE_BACKSTOP_TICKS === markerCreationTick) { + writeFileSync(markerPath, 'gitdir: elsewhere') + } + }, + onPendingMarkerProbe: (path) => { + pendingMarkerProbes += 1 + if (writeMarkerOnNextProbe && path === markerPath) { + writeMarkerOnNextProbe = false + writeFileSync(markerPath, 'gitdir: elsewhere') + } + } + } + ) + cleanups.push(() => poller.unsubscribe()) + + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === markerPath) + ) + + await rm(ordinaryFolder, { recursive: true }) + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'delete' && event.path === ordinaryFolder) + ) + writeMarkerOnNextProbe = true + await mkdir(ordinaryFolder) + const events = await waitForEvents( + received, + (flat) => + flat.filter((event) => event.type === 'create' && event.path === markerPath).length === 2 + ) + + expect(pendingMarkerProbes).toBeLessThanOrEqual(pendingMarkerMaxTicks) + expect( + events.filter((event) => event.type === 'create' && event.path === markerPath) + ).toHaveLength(2) + }) + it('parks base scans while hidden and losslessly detects changes on resume', async () => { const root = await makeRoot() const visibility = createVisibilityHarness() diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index 2f65e0fc282..b5076c2ed7f 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -63,6 +63,10 @@ export type WorktreeBasePollerOptions = { onWatchError?: (error: Error) => void /** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */ onFullScan?: () => void + /** Test hook: called before a pending `.git` marker stat. */ + onPendingMarkerProbe?: (path: string) => void + /** Test hook: overrides the fast-probe window. */ + pendingMarkerMaxTicks?: number } // Why: these targets used to be recursive FSEvents subscriptions spanning the @@ -193,7 +197,7 @@ async function startBasePoller( onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, visibility: WorktreePollerWindowVisibility, - onFullScan?: () => void + options: WorktreeBasePollerOptions ): Promise { let disposed = false let ticking = false @@ -202,16 +206,17 @@ async function startBasePoller( let gateSignatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) let timer: ReturnType | null = null let parkedWhileHidden = false - // dir → tick when first seen without a `.git` marker - const pendingMarkers = new Map() + const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS + // dir → first probe tick; null means backstop scans only + const markerProbeStartedAt = new Map() for (const [dir, marker] of snapshot.markers) { if (!marker) { - pendingMarkers.set(dir, 0) + markerProbeStartedAt.set(dir, 0) } } const fullScan = async (): Promise => { - onFullScan?.() + options.onFullScan?.() const next = await snapshotBase(target.path, getRepos()) const nextSignatures = await Promise.all(next.gateDirs.map(dirSignature)) if (disposed) { @@ -220,14 +225,14 @@ async function startBasePoller( const events = diffBase(snapshot, next) for (const [dir, marker] of next.markers) { if (marker) { - pendingMarkers.delete(dir) - } else if (!pendingMarkers.has(dir)) { - pendingMarkers.set(dir, tickCount) + markerProbeStartedAt.delete(dir) + } else if (!markerProbeStartedAt.has(dir)) { + markerProbeStartedAt.set(dir, tickCount) } } - for (const [dir, firstSeenTick] of pendingMarkers) { - if (!next.markers.has(dir) || tickCount - firstSeenTick > PENDING_MARKER_MAX_TICKS) { - pendingMarkers.delete(dir) + for (const dir of markerProbeStartedAt.keys()) { + if (!next.markers.has(dir)) { + markerProbeStartedAt.delete(dir) } } snapshot = next @@ -239,9 +244,17 @@ async function startBasePoller( const checkPendingMarkers = async (): Promise => { const events: WorktreeBasePollEvent[] = [] - for (const dir of pendingMarkers.keys()) { + for (const [dir, firstSeenTick] of markerProbeStartedAt) { + if (firstSeenTick === null) { + continue + } + if (tickCount - firstSeenTick > pendingMarkerMaxTicks) { + markerProbeStartedAt.set(dir, null) + continue + } + options.onPendingMarkerProbe?.(join(dir, '.git')) if (await hasGitMarker(dir)) { - pendingMarkers.delete(dir) + markerProbeStartedAt.delete(dir) snapshot.markers.set(dir, true) events.push({ type: 'create', path: join(dir, '.git') }) } @@ -267,7 +280,7 @@ async function startBasePoller( await fullScan() return } - if (pendingMarkers.size > 0) { + if (markerProbeStartedAt.size > 0) { await checkPendingMarkers() } } @@ -356,5 +369,5 @@ export async function startWorktreeBaseDirectoryPoller( options.onWatchError ) } - return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options.onFullScan) + return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options) } From 7dce0442abdadbb611db8e487eeb71b8af9e2e7d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:52:11 -0700 Subject: [PATCH 029/271] Add Caffeinate controls to the status bar (#13480) * feat: add caffeinate status controls * refactor: compact caffeinate status * fix: harden caffeinate readiness --- src/main/agent-awake-service.test.ts | 36 ++++ src/main/agent-awake-service.ts | 60 +++++- src/main/index.ts | 8 +- src/main/ipc/settings.test.ts | 15 +- src/main/ipc/settings.ts | 41 +++- src/preload/api-types.ts | 5 + src/preload/index.ts | 11 ++ .../components/feature-wall/KeepAwakeCard.tsx | 12 +- .../components/settings/AgentAwakeSetting.tsx | 57 +++--- .../components/settings/AgentsPane.test.tsx | 48 ++--- .../src/components/settings/AgentsPane.tsx | 5 +- .../components/settings/agent-awake-copy.ts | 14 +- .../src/components/settings/agents-search.ts | 13 +- .../status-bar/CaffeinateStatusSegment.tsx | 177 ++++++++++++++++++ .../src/components/status-bar/StatusBar.tsx | 3 + .../hooks/useSettingsNavigationMetadata.ts | 5 +- src/renderer/src/i18n/locales/en.json | 22 ++- src/renderer/src/web/web-preload-api.ts | 35 ++++ src/shared/computer-awake-mode.test.ts | 33 ++++ src/shared/computer-awake-mode.ts | 36 ++++ src/shared/types.ts | 3 + tests/e2e/settings-agent-awake.spec.ts | 34 ++-- tests/e2e/status-bar-caffeinate.spec.ts | 70 +++++++ 23 files changed, 647 insertions(+), 96 deletions(-) create mode 100644 src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx create mode 100644 src/shared/computer-awake-mode.test.ts create mode 100644 src/shared/computer-awake-mode.ts create mode 100644 tests/e2e/status-bar-caffeinate.spec.ts diff --git a/src/main/agent-awake-service.test.ts b/src/main/agent-awake-service.test.ts index 12ae01f3fc7..6a85e45c41a 100644 --- a/src/main/agent-awake-service.test.ts +++ b/src/main/agent-awake-service.test.ts @@ -122,6 +122,42 @@ describe('AgentAwakeService', () => { expect(linuxAssertion.start).toHaveBeenCalledTimes(1) }) + it('stays awake in On mode without a working agent', () => { + const blocker = createBlocker() + const service = createService(() => 1_000, blocker) + + service.setMode('on') + + expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep') + expect(service.getStatus()).toEqual({ mode: 'on', active: true }) + }) + + it('publishes Auto activity changes to status subscribers', () => { + const service = createService(() => 1_000) + const listener = vi.fn() + service.subscribe(listener) + + service.setMode('auto') + service.setStatuses([workingStatus()]) + service.setStatuses([workingStatus(), workingStatus()]) + service.setStatuses([workingStatus()]) + service.setStatuses([]) + + expect(listener).toHaveBeenNthCalledWith(1, { + mode: 'auto', + active: false + }) + expect(listener).toHaveBeenNthCalledWith(2, { + mode: 'auto', + active: true + }) + expect(listener).toHaveBeenNthCalledWith(3, { + mode: 'auto', + active: false + }) + expect(listener).toHaveBeenCalledTimes(3) + }) + it('starts and stops from settings flips around an already-running status', () => { const blocker = createBlocker() const macosAssertion = createMacosAssertion() diff --git a/src/main/agent-awake-service.ts b/src/main/agent-awake-service.ts index a0cffcf27c4..adba93e7ea1 100644 --- a/src/main/agent-awake-service.ts +++ b/src/main/agent-awake-service.ts @@ -1,5 +1,10 @@ import { powerMonitor, powerSaveBlocker } from 'electron' import type { AgentStatusState } from '../shared/agent-status-types' +import { + normalizeComputerAwakeMode, + type ComputerAwakeMode, + type ComputerAwakeStatus +} from '../shared/computer-awake-mode' import { LinuxLidSleepAssertion } from './linux-lid-sleep-assertion' import { MacosSystemSleepAssertion } from './macos-system-sleep-assertion' @@ -40,10 +45,12 @@ type AgentAwakeServiceOptions = { } export class AgentAwakeService { - private enabled = false + private mode: ComputerAwakeMode = 'off' private statuses: AgentAwakeStatus[] = [] private blockerId: number | null = null private staleTimer: ReturnType | null = null + private readonly statusListeners = new Set<(status: ComputerAwakeStatus) => void>() + private lastPublishedStatus: ComputerAwakeStatus | null = null private readonly blocker: PowerSaveBlocker private readonly linuxAssertion: PlatformAwakeAssertion private readonly logger: Logger @@ -82,10 +89,15 @@ export class AgentAwakeService { } setEnabled(enabled: boolean): void { - if (this.enabled === enabled) { + this.setMode(enabled ? 'auto' : 'off') + } + + setMode(mode: ComputerAwakeMode): void { + const normalized = normalizeComputerAwakeMode(mode) + if (this.mode === normalized) { return } - this.enabled = enabled + this.mode = normalized this.refresh('settings-change') } @@ -94,6 +106,19 @@ export class AgentAwakeService { this.refresh('status-change') } + getStatus(): ComputerAwakeStatus { + const workingAgentCount = this.getEligibleRunningStatusCount() + return { + mode: this.mode, + active: this.mode === 'on' || (this.mode === 'auto' && workingAgentCount > 0) + } + } + + subscribe(listener: (status: ComputerAwakeStatus) => void): () => void { + this.statusListeners.add(listener) + return () => this.statusListeners.delete(listener) + } + dispose(): void { this.clearStaleTimer() this.unsubscribeResume?.() @@ -105,7 +130,7 @@ export class AgentAwakeService { private refresh(reason: string): void { this.scheduleStaleTimer() const runningStatusCount = this.getEligibleRunningStatusCount() - const shouldBlock = this.enabled && runningStatusCount > 0 + const shouldBlock = this.mode === 'on' || (this.mode === 'auto' && runningStatusCount > 0) if (shouldBlock) { this.startBlocker(reason, runningStatusCount) this.startMacosAssertion(reason) @@ -115,6 +140,21 @@ export class AgentAwakeService { this.stopMacosAssertion(reason) this.stopLinuxAssertion(reason) } + this.publishStatus(shouldBlock) + } + + private publishStatus(active: boolean): void { + const status = { mode: this.mode, active } + if ( + this.lastPublishedStatus?.mode === status.mode && + this.lastPublishedStatus.active === status.active + ) { + return + } + this.lastPublishedStatus = status + for (const listener of this.statusListeners) { + listener(status) + } } private getEligibleRunningStatusCount(): number { @@ -182,7 +222,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to start blocker', { reason, - enabled: this.enabled, + mode: this.mode, runningStatusCount, error: err }) @@ -195,7 +235,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to start macOS system sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -207,7 +247,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to start Linux lid sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -219,7 +259,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to stop macOS system sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -231,7 +271,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to stop Linux lid sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -247,7 +287,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to stop blocker', { reason, - enabled: this.enabled, + mode: this.mode, runningStatusCount, blockerId: id, error: err diff --git a/src/main/index.ts b/src/main/index.ts index ddf79fd9455..0a4be2562c8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -267,6 +267,7 @@ import { AutomationService } from './automations/service' import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' import { AgentAwakeService } from './agent-awake-service' +import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode' import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { settleTeardownWithinDeadline } from './quit-teardown-deadline' import { quitTeardownStartGate } from './quit-teardown-start-gate' @@ -2216,7 +2217,12 @@ void app.whenReady().then(async () => { }) unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() agentAwakeService = new AgentAwakeService() - agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun) + agentAwakeService.setMode( + normalizeComputerAwakeMode( + store.getSettings().computerAwakeMode, + store.getSettings().keepComputerAwakeWhileAgentsRun + ) + ) // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. agentAwakeService.setStatuses([]) const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 88199db7e41..af7117b086f 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -344,7 +344,7 @@ describe('registerSettingsHandlers', () => { }) it('updates the agent awake service when the keep-awake setting changes', () => { - const agentAwakeService = { setEnabled: vi.fn() } + const agentAwakeService = { setMode: vi.fn() } store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false }) store.updateSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: true }) registerSettingsHandlers(store as never, agentAwakeService as never) @@ -356,11 +356,18 @@ describe('registerSettingsHandlers', () => { handler(settingsInvokeEvent, { keepComputerAwakeWhileAgentsRun: true }) - expect(agentAwakeService.setEnabled).toHaveBeenCalledWith(true) + expect(store.updateSettings).toHaveBeenCalledWith( + { + computerAwakeMode: 'auto', + keepComputerAwakeWhileAgentsRun: true + }, + expect.any(Object) + ) + expect(agentAwakeService.setMode).toHaveBeenCalledWith('auto') }) it('does not notify the agent awake service for unrelated setting changes', () => { - const agentAwakeService = { setEnabled: vi.fn() } + const agentAwakeService = { setMode: vi.fn() } store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false }) store.updateSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false }) registerSettingsHandlers(store as never, agentAwakeService as never) @@ -372,7 +379,7 @@ describe('registerSettingsHandlers', () => { handler(settingsInvokeEvent, { defaultTuiAgent: 'codex' }) - expect(agentAwakeService.setEnabled).not.toHaveBeenCalled() + expect(agentAwakeService.setMode).not.toHaveBeenCalled() }) it('prepares local worktree roots when workspace directory changes', async () => { diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index df3817c949c..98e918300ab 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -29,6 +29,10 @@ import { normalizeMobilePairingCustomAddress, normalizeMobilePairingCustomAddresses } from '../../shared/mobile-pairing-custom-address' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode +} from '../../shared/computer-awake-mode' // Why: the whitelist is the source-of-truth for which keys we emit on. Casting // to a Set once at module load lets the IPC handler's per-key membership @@ -65,6 +69,18 @@ export function registerSettingsHandlers( store: Store, agentAwakeService?: AgentAwakeService ): void { + ipcMain.handle( + 'agentAwake:getStatus', + () => agentAwakeService?.getStatus() ?? { mode: 'off', active: false } + ) + agentAwakeService?.subscribe?.((status) => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send('agentAwake:changed', status) + } + } + }) + store.onSettingsChanged((updates, _settings, originWebContentsId) => { for (const window of BrowserWindow.getAllWindows()) { const isOrigin = @@ -109,6 +125,22 @@ export function registerSettingsHandlers( // Why: Floating Workspace grants are trusted only when written by the // main-process directory picker, never by renderer-provided settings IPC. delete sanitizedArgs.floatingTerminalTrustedCwds + if ('computerAwakeMode' in sanitizedArgs) { + Object.assign( + sanitizedArgs, + computerAwakeSettingsForMode( + normalizeComputerAwakeMode( + sanitizedArgs.computerAwakeMode, + sanitizedArgs.keepComputerAwakeWhileAgentsRun + ) + ) + ) + } else if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) { + Object.assign( + sanitizedArgs, + computerAwakeSettingsForMode(sanitizedArgs.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off') + ) + } if (typeof args.floatingTerminalCwd === 'string') { sanitizedArgs.floatingTerminalCwd = await sanitizeFloatingWorkspaceDirectorySetting( store, @@ -161,8 +193,13 @@ export function registerSettingsHandlers( notifyListeners: true, originWebContentsId: event.sender.id }) - if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) { - agentAwakeService?.setEnabled(result.keepComputerAwakeWhileAgentsRun) + if ( + 'computerAwakeMode' in sanitizedArgs || + 'keepComputerAwakeWhileAgentsRun' in sanitizedArgs + ) { + agentAwakeService?.setMode( + normalizeComputerAwakeMode(result.computerAwakeMode, result.keepComputerAwakeWhileAgentsRun) + ) } const hookSettingChanged = ('agentStatusHooksEnabled' in sanitizedArgs && diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 87c8fce5901..1cae8cd2359 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,7 @@ import type { HostedReviewProvider } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' import type { BrowserFindSource } from '../shared/browser-find-source' import type { DashboardRevealAgentArgs, @@ -2425,6 +2426,10 @@ export type PreloadApi = { /** Subscribe to out-of-band settings updates (e.g. View > Appearance toggles) to stay in sync with main. */ onChanged: (callback: (updates: Partial) => void) => () => void } + agentAwake: { + getStatus: () => Promise + onChanged: (callback: (status: ComputerAwakeStatus) => void) => () => void + } localhostWorktreeLabels: { register: (args: LocalhostWorktreeLabelRoute) => Promise } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2cf88323b23..179ce5ecc2a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,6 +4,7 @@ import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' import type { AppIdentity } from '../shared/app-identity' +import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' import type { DashboardRevealAgentArgs, DashboardSleepWorkspaceArgs, @@ -2047,6 +2048,16 @@ const api = { } }, + agentAwake: { + getStatus: (): Promise => ipcRenderer.invoke('agentAwake:getStatus'), + onChanged: (callback: (status: ComputerAwakeStatus) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: ComputerAwakeStatus): void => + callback(status) + ipcRenderer.on('agentAwake:changed', listener) + return () => ipcRenderer.removeListener('agentAwake:changed', listener) + } + } satisfies PreloadApi['agentAwake'], + localhostWorktreeLabels: { register: (args: LocalhostWorktreeLabelRoute): Promise => ipcRenderer.invoke('localhostWorktreeLabels:register', args) diff --git a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx index 2a7b57adc67..40d25ebf8bb 100644 --- a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx +++ b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx @@ -3,13 +3,21 @@ import { cn } from '@/lib/utils' import { getAgentAwakeDescription, getAgentAwakeTitle } from '../settings/agent-awake-copy' import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode +} from '../../../../shared/computer-awake-mode' export function KeepAwakeCard(props: { settings: GlobalSettings updateSettings: (updates: Partial) => void }): JSX.Element { const { settings, updateSettings } = props - const enabled = settings.keepComputerAwakeWhileAgentsRun + const enabled = + normalizeComputerAwakeMode( + settings.computerAwakeMode, + settings.keepComputerAwakeWhileAgentsRun + ) !== 'off' const title = getAgentAwakeTitle() return (
@@ -29,7 +37,7 @@ export function KeepAwakeCard(props: { role="switch" aria-label={title} aria-checked={enabled} - onClick={() => updateSettings({ keepComputerAwakeWhileAgentsRun: !enabled })} + onClick={() => updateSettings(computerAwakeSettingsForMode(enabled ? 'off' : 'auto'))} className={cn( 'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors', enabled ? 'bg-foreground' : 'bg-muted-foreground/30' diff --git a/src/renderer/src/components/settings/AgentAwakeSetting.tsx b/src/renderer/src/components/settings/AgentAwakeSetting.tsx index 28cd7cc0114..7df934be0c7 100644 --- a/src/renderer/src/components/settings/AgentAwakeSetting.tsx +++ b/src/renderer/src/components/settings/AgentAwakeSetting.tsx @@ -6,6 +6,13 @@ import { getAgentAwakeTitle } from './agent-awake-copy' import { SearchableSetting } from './SearchableSetting' +import { SettingsSegmentedControl } from './SettingsFormControls' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode, + type ComputerAwakeMode +} from '../../../../shared/computer-awake-mode' +import { translate } from '@/i18n/i18n' type AgentAwakeSettingProps = { settings: GlobalSettings @@ -18,6 +25,13 @@ export function AgentAwakeSetting({ }: AgentAwakeSettingProps): React.JSX.Element { const title = getAgentAwakeTitle() const description = getAgentAwakeDescription() + const mode = normalizeComputerAwakeMode( + settings.computerAwakeMode, + settings.keepComputerAwakeWhileAgentsRun + ) + const setMode = (nextMode: ComputerAwakeMode): void => { + updateSettings(computerAwakeSettingsForMode(nextMode)) + } return (
@@ -31,29 +45,26 @@ export function AgentAwakeSetting({

{description}

- {/* Why: this button is read directly from the React element tree by tests - that walk props (without rendering), so the role/aria attributes - must remain on a literal + diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index 4a8428ca892..af821f94920 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -117,19 +117,6 @@ function visit(node: unknown, cb: (node: ReactElementLike) => void): void { } } -function findSwitch(node: unknown, ariaLabel: string): ReactElementLike { - let found: ReactElementLike | null = null - visit(node, (entry) => { - if (entry.props.role === 'switch' && entry.props['aria-label'] === ariaLabel) { - found = entry - } - }) - if (!found) { - throw new Error('switch not found') - } - return found -} - function findSwitchRow(node: unknown, ariaLabel: string): ReactElementLike { let found: ReactElementLike | null = null visit(node, (entry) => { @@ -249,17 +236,30 @@ describe('AgentsPane', () => { expect(agentRuntimeSettingMock.lastRefresh).not.toBe(detectedAgentsMock.refresh) }) - it('renders the keep-awake toggle from settings', () => { + it('renders the keep-awake modes from settings', () => { const markup = renderPane(getDefaultSettings('/tmp')) expect(markup).not.toContain('Agent location') expect(markup).not.toContain('Agent runtime') expect(markup).not.toContain('aria-label="Agent runtime"') - expect(markup).toContain('Keep computer awake while agents are working') + expect(markup).toContain('Keep computer awake') expect(markup).toContain( - 'Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' + 'Choose On, Auto while agents are working, or Off. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' ) - expect(markup).toContain('aria-checked="false"') + expect(markup).toContain('role="radiogroup"') + expect(markup).toContain('>Auto<') + }) + + it('hides desktop-only awake modes in paired web clients', () => { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + try { + expect(renderPane(getDefaultSettings('/tmp'))).not.toContain('Keep computer awake') + expect( + matchesSettingsSearch('awake', getAgentsPaneSearchEntries({ includeAgentAwake: false })) + ).toBe(false) + } finally { + delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ + } }) it('renders the agent runtime control on Windows-class hosts', () => { @@ -316,11 +316,11 @@ describe('AgentsPane', () => { it('describes Windows lid behavior according to the device', () => { expect(getAgentAwakeDescription('Windows')).toBe( - "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings." + "Choose On, Auto while agents are working, or Off. Lid-close behavior follows this device's power settings." ) }) - it('toggles the keep-awake setting with the next value', () => { + it('updates the keep-awake mode with its legacy fallback', () => { const updateSettings = vi.fn() const element = AgentAwakeSetting({ settings: { @@ -331,14 +331,14 @@ describe('AgentsPane', () => { }) const keepAwakeTitle = getAgentAwakeTitle() - const keepAwakeSwitch = findSwitch(element, keepAwakeTitle) - expect(keepAwakeSwitch.props['aria-label']).toBe(keepAwakeTitle) - expect(keepAwakeSwitch.props['aria-checked']).toBe(false) + const keepAwakeControl = findSegmentedControl(element, keepAwakeTitle) + expect(keepAwakeControl.props.value).toBe('off') - const onClick = keepAwakeSwitch.props.onClick as () => void - onClick() + const onChange = keepAwakeControl.props.onChange as (mode: 'auto') => void + onChange('auto') expect(updateSettings).toHaveBeenCalledWith({ + computerAwakeMode: 'auto', keepComputerAwakeWhileAgentsRun: true }) }) diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 21f50bede40..70ba5f64c3f 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -56,6 +56,7 @@ import { getSettingOwnershipSummary } from './setting-ownership' import { translate } from '@/i18n/i18n' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { parseAgentDefaultEnvDraft, stringifyAgentDefaultEnvDraft } from './agent-default-env-draft' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' export { getAgentsPaneSearchEntries } from './agents-search' @@ -867,7 +868,9 @@ export function AgentsPane({ - + {!isPairedWebClientWindow() ? ( + + ) : null} diff --git a/src/renderer/src/components/settings/agent-awake-copy.ts b/src/renderer/src/components/settings/agent-awake-copy.ts index 10d629b58d1..1076312ed18 100644 --- a/src/renderer/src/components/settings/agent-awake-copy.ts +++ b/src/renderer/src/components/settings/agent-awake-copy.ts @@ -1,12 +1,14 @@ import { translate } from '@/i18n/i18n' import { searchKeywords } from './settings-search-keywords' -const AGENT_AWAKE_TITLE_KEY = 'auto.components.settings.agent-awake-copy.e5995ce268' -const AGENT_AWAKE_DESCRIPTION_WINDOWS_KEY = 'auto.components.settings.agent-awake-copy.95d3031db2' -const AGENT_AWAKE_DESCRIPTION_DEFAULT_KEY = 'auto.components.settings.agent-awake-copy.a42f6fbdd8' +const AGENT_AWAKE_TITLE_KEY = 'auto.components.settings.agent-awake-copy.modeTitle' +const AGENT_AWAKE_DESCRIPTION_WINDOWS_KEY = + 'auto.components.settings.agent-awake-copy.modeDescriptionWindows' +const AGENT_AWAKE_DESCRIPTION_DEFAULT_KEY = + 'auto.components.settings.agent-awake-copy.modeDescriptionDefault' export function getAgentAwakeTitle(): string { - return translate(AGENT_AWAKE_TITLE_KEY, 'Keep computer awake while agents are working') + return translate(AGENT_AWAKE_TITLE_KEY, 'Keep computer awake') } export function getAgentAwakeDescription( @@ -15,13 +17,13 @@ export function getAgentAwakeDescription( if (userAgent.includes('Windows')) { return translate( AGENT_AWAKE_DESCRIPTION_WINDOWS_KEY, - "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings." + "Choose On, Auto while agents are working, or Off. Lid-close behavior follows this device's power settings." ) } return translate( AGENT_AWAKE_DESCRIPTION_DEFAULT_KEY, - 'Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' + 'Choose On, Auto while agents are working, or Off. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' ) } diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts index 5e378d732d1..ed353d5acbf 100644 --- a/src/renderer/src/components/settings/agents-search.ts +++ b/src/renderer/src/components/settings/agents-search.ts @@ -60,9 +60,11 @@ function expandAgentSearchText(value: string): string[] { } type AgentsPaneSearchOptions = { + includeAgentAwake?: boolean includeAgentRuntime?: boolean } +const AGENT_AWAKE_SEARCH_ENTRY_ID = 'agent-awake' const AGENT_RUNTIME_SEARCH_ENTRY_ID = 'agent-runtime' const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ @@ -113,6 +115,7 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ }, { title: getAgentAwakeTitle(), + id: AGENT_AWAKE_SEARCH_ENTRY_ID, description: getAgentAwakeDescription(), keywords: getAgentAwakeSearchKeywords() }, @@ -141,11 +144,13 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ ]) export function getAgentsPaneSearchEntries({ + includeAgentAwake = true, includeAgentRuntime = true }: AgentsPaneSearchOptions = {}) { const entries = getAllAgentsPaneSearchEntries() - if (includeAgentRuntime) { - return entries - } - return entries.filter((entry) => !('id' in entry) || entry.id !== AGENT_RUNTIME_SEARCH_ENTRY_ID) + return entries.filter( + (entry) => + (!('id' in entry) || entry.id !== AGENT_RUNTIME_SEARCH_ENTRY_ID || includeAgentRuntime) && + (!('id' in entry) || entry.id !== AGENT_AWAKE_SEARCH_ENTRY_ID || includeAgentAwake) + ) } diff --git a/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx b/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx new file mode 100644 index 00000000000..b991e63a096 --- /dev/null +++ b/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx @@ -0,0 +1,177 @@ +import { useEffect, useState } from 'react' +import { Coffee } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' +import { translate } from '@/i18n/i18n' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode, + type ComputerAwakeMode, + type ComputerAwakeStatus +} from '../../../../shared/computer-awake-mode' +import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' + +const INACTIVE_STATUS: ComputerAwakeStatus = { + mode: 'off', + active: false +} + +function modeLabel(mode: ComputerAwakeMode): string { + if (mode === 'on') { + return translate('auto.components.status.bar.CaffeinateStatusSegment.on', 'On') + } + if (mode === 'auto') { + return translate('auto.components.status.bar.CaffeinateStatusSegment.auto', 'Auto') + } + return translate('auto.components.status.bar.CaffeinateStatusSegment.off', 'Off') +} + +function activityLabel(active: boolean): string { + return active + ? translate('auto.components.status.bar.CaffeinateStatusSegment.active', 'Active') + : translate('auto.components.status.bar.CaffeinateStatusSegment.inactive', 'Inactive') +} + +export function CaffeinateStatusSegment({ + iconOnly +}: { + iconOnly: boolean +}): React.JSX.Element | null { + const settings = useAppStore((state) => state.settings) + const updateSettings = useAppStore((state) => state.updateSettings) + const configuredMode = normalizeComputerAwakeMode( + settings?.computerAwakeMode, + settings?.keepComputerAwakeWhileAgentsRun + ) + const [serviceStatus, setServiceStatus] = useState(INACTIVE_STATUS) + + useEffect(() => { + let mounted = true + const unsubscribe = window.api.agentAwake.onChanged((status) => { + if (mounted) { + setServiceStatus(status) + } + }) + void window.api.agentAwake + .getStatus() + .then((status) => { + if (mounted) { + setServiceStatus(status) + } + }) + .catch(() => {}) + return () => { + mounted = false + unsubscribe() + } + }, []) + + if (isPairedWebClientWindow()) { + return null + } + + const mode = serviceStatus.mode === configuredMode ? serviceStatus.mode : configuredMode + const active = + serviceStatus.mode === configuredMode ? serviceStatus.active : configuredMode === 'on' + const statusText = `${modeLabel(mode)} · ${activityLabel(active)}` + const ariaLabel = translate( + 'auto.components.status.bar.CaffeinateStatusSegment.ariaLabel', + 'Caffeinate, {{status}}', + { status: statusText } + ) + + const setMode = (nextMode: string): void => { + void updateSettings(computerAwakeSettingsForMode(normalizeComputerAwakeMode(nextMode))) + } + + return ( + + + + + + + + + {ariaLabel} + + + + + + {translate('auto.components.status.bar.CaffeinateStatusSegment.title', 'Caffeinate')} + + {statusText} + + + + + + {modeLabel('on')} + + {translate( + 'auto.components.status.bar.CaffeinateStatusSegment.onDescription', + 'Keep this computer awake continuously' + )} + + + + + + {modeLabel('auto')} + + {translate( + 'auto.components.status.bar.CaffeinateStatusSegment.autoDescription', + 'Stay awake while an agent is working' + )} + + + + + + {modeLabel('off')} + + {translate( + 'auto.components.status.bar.CaffeinateStatusSegment.offDescription', + 'Allow normal system sleep behavior' + )} + + + + + + + ) +} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 1b3075e305b..1c4af8fdff8 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -71,6 +71,7 @@ import { } from '@/lib/codex-session-restart' import { UpdateStatusSegment } from './UpdateStatusSegment' import { SkillUpdateStatusSegment } from './SkillUpdateStatusSegment' +import { CaffeinateStatusSegment } from './CaffeinateStatusSegment' import { RemoteServerUpdateStatusSegment } from './RemoteServerUpdateStatusSegment' import { isStatusBarItemAvailable } from './status-bar-agent-gating' import { getVisibleUsageProvider, isUsageEmptyState } from './status-bar-provider-visibility' @@ -84,6 +85,7 @@ import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { FloatingTerminalIconContextMenu } from '@/components/floating-terminal/FloatingTerminalIconContextMenu' import { summarizeCodexRestartStatus } from './codex-restart-status-summary' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { getWindowsTerminalCapabilityOwnerKey, useWindowsTerminalCapabilities @@ -2349,6 +2351,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
+ {!isPairedWebClientWindow() ? : null} diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 17f535457ee..364d525298b 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -165,7 +165,10 @@ export function buildSettingsNavigationMetadata({ 'Manage AI agents, set a default, and customize commands.' ), icon: Bot, - searchEntries: getAgentsPaneSearchEntries({ includeAgentRuntime: isLocalWindowsHost }), + searchEntries: getAgentsPaneSearchEntries({ + includeAgentAwake: !isWebClient, + includeAgentRuntime: isLocalWindowsHost + }), group: 'capabilities' }, { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index baadf663b56..1aaead6e8f0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3635,6 +3635,18 @@ } } } + }, + "CaffeinateStatusSegment": { + "on": "On", + "auto": "Auto", + "off": "Off", + "active": "Active", + "inactive": "Inactive", + "ariaLabel": "Caffeinate, {{status}}", + "title": "Caffeinate", + "onDescription": "Keep this computer awake continuously", + "autoDescription": "Stay awake while an agent is working", + "offDescription": "Allow normal system sleep behavior" } } }, @@ -9455,7 +9467,10 @@ "agent-awake-copy": { "e5995ce268": "Keep computer awake while agents are working", "95d3031db2": "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings.", - "a42f6fbdd8": "Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy." + "a42f6fbdd8": "Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.", + "modeTitle": "Keep computer awake", + "modeDescriptionWindows": "Choose On, Auto while agents are working, or Off. Lid-close behavior follows this device's power settings.", + "modeDescriptionDefault": "Choose On, Auto while agents are working, or Off. Orca also asks this device to stay awake when the lid is closed, subject to its power policy." }, "agent-status-hooks-copy": { "7707c15abb": "Agent status hooks", @@ -10514,6 +10529,11 @@ "openAutomationsDescription": "Create schedules and inspect recent runs.", "title": "Automations", "description": "Schedule agent work and choose whether Automations appears in the sidebar." + }, + "AgentAwakeSetting": { + "on": "On", + "auto": "Auto", + "off": "Off" } }, "right": { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 038e05e1373..77f9a40dfdb 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -92,6 +92,10 @@ import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-t import { normalizeUiLanguage } from '../../../shared/ui-language' import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display' import { normalizeStatusBarUsageMode } from '../../../shared/status-bar-usage-mode' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode +} from '../../../shared/computer-awake-mode' import type { RateLimitState } from '../../../shared/rate-limit-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types' import { assertFileMutationOwnershipCapability } from '../../../shared/file-mutation-ownership' @@ -680,6 +684,24 @@ function createWebPreloadApi(): Partial { set: async (updates) => { const sanitizedUpdates = { ...updates } delete sanitizedUpdates.activeRuntimeEnvironmentId + if ('computerAwakeMode' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + computerAwakeSettingsForMode( + normalizeComputerAwakeMode( + sanitizedUpdates.computerAwakeMode, + sanitizedUpdates.keepComputerAwakeWhileAgentsRun + ) + ) + ) + } else if ('keepComputerAwakeWhileAgentsRun' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + computerAwakeSettingsForMode( + sanitizedUpdates.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off' + ) + ) + } if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) { sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true } @@ -704,6 +726,19 @@ function createWebPreloadApi(): Partial { listFonts: () => Promise.resolve([]), onChanged: () => noopUnsubscribe } satisfies Partial as unknown as WebSettingsApi, + agentAwake: { + getStatus: async () => { + const settings = getStoredSettings() + return { + mode: normalizeComputerAwakeMode( + settings.computerAwakeMode, + settings.keepComputerAwakeWhileAgentsRun + ), + active: false + } + }, + onChanged: () => noopUnsubscribe + }, keybindings: createWebKeybindingsApi(), ui: createWebUiApi(), crashReports: { diff --git a/src/shared/computer-awake-mode.test.ts b/src/shared/computer-awake-mode.test.ts new file mode 100644 index 00000000000..5a0920f4c0c --- /dev/null +++ b/src/shared/computer-awake-mode.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { computerAwakeSettingsForMode, normalizeComputerAwakeMode } from './computer-awake-mode' + +describe('computer awake mode', () => { + it('maps the legacy enabled setting to Auto', () => { + expect(normalizeComputerAwakeMode(undefined, true)).toBe('auto') + expect(normalizeComputerAwakeMode(undefined, false)).toBe('off') + }) + + it('preserves explicit modes when the legacy projection agrees or is absent', () => { + expect(normalizeComputerAwakeMode('on')).toBe('on') + expect(normalizeComputerAwakeMode('on', true)).toBe('on') + expect(normalizeComputerAwakeMode('off', false)).toBe('off') + expect(normalizeComputerAwakeMode('auto', true)).toBe('auto') + }) + + it('honors legacy changes made by a rollback build', () => { + expect(normalizeComputerAwakeMode('on', false)).toBe('off') + expect(normalizeComputerAwakeMode('auto', false)).toBe('off') + expect(normalizeComputerAwakeMode('off', true)).toBe('auto') + }) + + it('writes a safe legacy approximation', () => { + expect(computerAwakeSettingsForMode('on')).toEqual({ + computerAwakeMode: 'on', + keepComputerAwakeWhileAgentsRun: true + }) + expect(computerAwakeSettingsForMode('off')).toEqual({ + computerAwakeMode: 'off', + keepComputerAwakeWhileAgentsRun: false + }) + }) +}) diff --git a/src/shared/computer-awake-mode.ts b/src/shared/computer-awake-mode.ts new file mode 100644 index 00000000000..0c598fd25d5 --- /dev/null +++ b/src/shared/computer-awake-mode.ts @@ -0,0 +1,36 @@ +export const COMPUTER_AWAKE_MODES = ['on', 'off', 'auto'] as const + +export type ComputerAwakeMode = (typeof COMPUTER_AWAKE_MODES)[number] + +export type ComputerAwakeStatus = { + mode: ComputerAwakeMode + active: boolean +} + +export function normalizeComputerAwakeMode( + mode: unknown, + legacyAutoEnabled?: boolean +): ComputerAwakeMode { + const explicitMode = COMPUTER_AWAKE_MODES.includes(mode as ComputerAwakeMode) + ? (mode as ComputerAwakeMode) + : null + if (!explicitMode) { + return legacyAutoEnabled === true ? 'auto' : 'off' + } + if (typeof legacyAutoEnabled === 'boolean' && legacyAutoEnabled !== (explicitMode !== 'off')) { + // Older builds can only change the legacy boolean, so disagreement means it was written later. + return legacyAutoEnabled ? 'auto' : 'off' + } + return explicitMode +} + +export function computerAwakeSettingsForMode(mode: ComputerAwakeMode): { + computerAwakeMode: ComputerAwakeMode + keepComputerAwakeWhileAgentsRun: boolean +} { + return { + computerAwakeMode: mode, + // Older Orca versions approximate On with their supported Auto behavior. + keepComputerAwakeWhileAgentsRun: mode !== 'off' + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 9eb3de46c3b..284782625e9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -53,6 +53,7 @@ import type { CodexResetCreditAttemptLedger } from './codex-reset-credit-attempt import type { TaskSourceContext } from './task-source-context' import type { SetupRunnerShell } from './setup-runner-command' import type { AiVaultSessionTitle } from './ai-vault-session-title' +import type { ComputerAwakeMode } from './computer-awake-mode' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -3050,6 +3051,8 @@ export type GlobalSettings = { confirmClosePinnedTab: boolean /** When true, Orca requests local awake assertions while hook-reported agents are working. */ keepComputerAwakeWhileAgentsRun: boolean + /** Optional for mixed-version compatibility; the legacy boolean maps true to Auto. */ + computerAwakeMode?: ComputerAwakeMode /** macOS Option key: compose layout chars (@ German, € French) vs act as Meta/Esc for readline. * 'auto' (default) = layout-aware via navigator.keyboard.getLayoutMap() (US → Meta, else compose); * 'false' = compose; 'true' = Meta on both Option keys; 'left'/'right' = only that key is Meta. diff --git a/tests/e2e/settings-agent-awake.spec.ts b/tests/e2e/settings-agent-awake.spec.ts index e35aed89c39..0432970b39c 100644 --- a/tests/e2e/settings-agent-awake.spec.ts +++ b/tests/e2e/settings-agent-awake.spec.ts @@ -139,37 +139,37 @@ test.describe('Agent awake setting', () => { await waitForSessionReady(orcaPage) }) - test('can be toggled from Agents settings and persists through IPC', async ({ orcaPage }) => { + test('can be changed from Agents settings and persists through IPC', async ({ orcaPage }) => { await openSettings(orcaPage) await dismissTransientAnnouncement(orcaPage) await orcaPage.getByPlaceholder('Search settings').fill('awake') - await expect( - orcaPage.getByText('Keep computer awake while agents are working').first() - ).toBeVisible() + await expect(orcaPage.getByText('Keep computer awake').first()).toBeVisible() - const keepAwakeSwitch = orcaPage.getByRole('switch', { - name: 'Keep computer awake while agents are working' + const keepAwakeModes = orcaPage.getByRole('radiogroup', { + name: 'Keep computer awake' }) + const offMode = keepAwakeModes.getByRole('radio', { name: 'Off' }) + const autoMode = keepAwakeModes.getByRole('radio', { name: 'Auto' }) - await expect(keepAwakeSwitch).toHaveAttribute('aria-checked', 'false') - await keepAwakeSwitch.click() - await expect(keepAwakeSwitch).toHaveAttribute('aria-checked', 'true') + await expect(offMode).toHaveAttribute('aria-checked', 'true') + await autoMode.click() + await expect(autoMode).toHaveAttribute('aria-checked', 'true') await expect - .poll(async () => (await getSettings(orcaPage)).keepComputerAwakeWhileAgentsRun, { + .poll(async () => (await getSettings(orcaPage)).computerAwakeMode, { timeout: 5_000, - message: 'keep-awake setting did not persist after enabling' + message: 'keep-awake mode did not persist after selecting Auto' }) - .toBe(true) + .toBe('auto') - await keepAwakeSwitch.click() - await expect(keepAwakeSwitch).toHaveAttribute('aria-checked', 'false') + await offMode.click() + await expect(offMode).toHaveAttribute('aria-checked', 'true') await expect - .poll(async () => (await getSettings(orcaPage)).keepComputerAwakeWhileAgentsRun, { + .poll(async () => (await getSettings(orcaPage)).computerAwakeMode, { timeout: 5_000, - message: 'keep-awake setting did not persist after disabling' + message: 'keep-awake mode did not persist after selecting Off' }) - .toBe(false) + .toBe('off') }) test('keeps the OS awake only while a hook-reported agent is working', async ({ diff --git a/tests/e2e/status-bar-caffeinate.spec.ts b/tests/e2e/status-bar-caffeinate.spec.ts new file mode 100644 index 00000000000..2e28770213d --- /dev/null +++ b/tests/e2e/status-bar-caffeinate.spec.ts @@ -0,0 +1,70 @@ +import { randomUUID } from 'node:crypto' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { readHookEndpoint } from './helpers/agent-hook-endpoint' + +async function postCodexHookEvent( + electronApp: ElectronApplication, + paneKey: string, + eventName: 'UserPromptSubmit' | 'Stop' +): Promise { + const endpoint = await readHookEndpoint(electronApp) + const response = await fetch(`http://127.0.0.1:${endpoint.port}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': endpoint.token + }, + body: JSON.stringify({ + paneKey, + tabId: 'e2e-caffeinate-tab', + worktreeId: 'e2e-caffeinate-worktree', + env: endpoint.env, + version: endpoint.version, + payload: { hook_event_name: eventName, prompt: 'e2e caffeinate prompt' } + }) + }) + expect(response.status).toBe(204) +} + +test('shows Caffeinate mode and Auto activity in the status bar', async ({ + electronApp, + orcaPage +}) => { + await waitForSessionReady(orcaPage) + + const offStatus = orcaPage.getByRole('button', { name: 'Caffeinate, Off · Inactive' }) + await expect(offStatus).toBeVisible() + await expect(offStatus).toHaveText('Off') + await offStatus.click() + await expect(orcaPage.getByRole('menuitemradio', { name: /^On/ })).toBeVisible() + await expect(orcaPage.getByRole('menuitemradio', { name: /^Auto/ })).toBeVisible() + await expect(orcaPage.getByRole('menuitemradio', { name: /^Off/ })).toBeVisible() + const menuProofPath = process.env.ORCA_CAFFEINATE_MENU_PROOF_PATH + if (menuProofPath) { + await orcaPage.screenshot({ path: menuProofPath }) + } + await orcaPage.getByRole('menuitemradio', { name: /^Auto/ }).click() + + const autoInactiveStatus = orcaPage.getByRole('button', { + name: 'Caffeinate, Auto · Inactive' + }) + await expect(autoInactiveStatus).toBeVisible() + + const paneKey = `e2e-caffeinate-tab:${randomUUID()}` + await postCodexHookEvent(electronApp, paneKey, 'UserPromptSubmit') + const autoActiveStatus = orcaPage.getByRole('button', { + name: 'Caffeinate, Auto · Active' + }) + await expect(autoActiveStatus).toBeVisible() + await expect(autoActiveStatus).toHaveText('Auto') + + const proofPath = process.env.ORCA_CAFFEINATE_PROOF_PATH + if (proofPath) { + await orcaPage.screenshot({ path: proofPath }) + } + + await postCodexHookEvent(electronApp, paneKey, 'Stop') + await expect(autoInactiveStatus).toBeVisible() +}) From d6362deb044d887db47779ae181ad93042354465 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:55:32 -0700 Subject: [PATCH 030/271] feat(agent-hooks): support Prime Agent status in WSL (#13430) --- .../wsl-hook-relay-manager.test.ts | 7 + .../agent-hooks/wsl-hook-relay-manager.ts | 12 +- src/main/daemon/shell-ready.test.ts | 5 + src/main/daemon/shell-ready.ts | 4 + src/main/ipc/pty.test.ts | 36 ++- src/main/ipc/pty.ts | 30 ++- .../pi/agent-status-extension-source.test.ts | 33 +++ src/main/pi/agent-status-extension-source.ts | 84 +----- src/main/pi/agent-status-wsl-curl-source.ts | 81 ++++++ .../pi/titlebar-extension-service.test.ts | 19 ++ src/main/pi/titlebar-extension-service.ts | 22 +- .../providers/local-pty-shell-ready.test.ts | 5 + src/main/providers/local-pty-shell-ready.ts | 4 + src/main/providers/windows-shell-args.test.ts | 7 +- .../pty/prime-agent-shell-wrapper.test.ts | 244 ++++++++++++++++++ src/main/pty/prime-agent-shell-wrapper.ts | 74 ++++++ src/main/pty/wsl-orca-env.test.ts | 5 + src/main/pty/wsl-orca-env.ts | 1 + 18 files changed, 575 insertions(+), 98 deletions(-) create mode 100644 src/main/pi/agent-status-wsl-curl-source.ts create mode 100644 src/main/pty/prime-agent-shell-wrapper.test.ts create mode 100644 src/main/pty/prime-agent-shell-wrapper.ts diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 4d2ae9b7bb5..cb538595c19 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -233,6 +233,13 @@ describe('WslHookRelayManager', () => { return { manager: new WslHookRelayManager(deps), deps } } + it('exposes the stable guest endpoint instance key with a port fallback', () => { + expect(createManager({}).manager.getInstanceKey()).toBe('testinstance') + expect(createManager({ instanceKey: () => 'unsafe/key' }).manager.getInstanceKey()).toBe( + 'port43117' + ) + }) + it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => { const { manager, deps } = createManager({}) manager.ensureForDistro('Ubuntu') diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 74d163ca42f..828e2fa888a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -109,6 +109,15 @@ export class WslHookRelayManager { return this.stateFor(distro)?.opencodeOverlayDir ?? null } + getInstanceKey(): string | null { + const stableKey = sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined) + if (stableKey) { + return stableKey + } + const port = Number(this.deps.hookCoordsEnv().ORCA_AGENT_HOOK_PORT ?? '') + return Number.isInteger(port) && port > 0 ? `port${port}` : null + } + /** Kills every live relay. Non-permanent (hooks switched off mid-session) leaves the * manager reusable, so re-enabling hooks can start relays again without an app restart. */ disposeAll({ permanent = true }: { permanent?: boolean } = {}): void { @@ -172,8 +181,7 @@ export class WslHookRelayManager { } // Why: restart-stable instance identity keeps the guest endpoint file at // ONE path across restarts so daemon-surviving agents re-coordinate. - const instanceKey = - sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined) ?? `port${port}` + const instanceKey = this.getInstanceKey() ?? `port${port}` if (existing) { this.recovery.clearTimers(existing) } diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 42e6ccc15f5..9610f2600d1 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -604,6 +604,8 @@ describePosix('daemon shell-ready launch config', () => { '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0' const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"' + const primeWrapperLine = + 'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"' expect(zshrc).toContain(restoreLine) expect(zlogin).toContain(restoreLine) expect(bashRc).toContain(restoreLine) @@ -625,6 +627,9 @@ describePosix('daemon shell-ready launch config', () => { expect(zshrc).toContain(ompWrapperLine) expect(zlogin).toContain(ompWrapperLine) expect(bashRc).toContain(ompWrapperLine) + expect(zshrc).toContain(primeWrapperLine) + expect(zlogin).toContain(primeWrapperLine) + expect(bashRc).toContain(primeWrapperLine) }) // Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows. diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 38a26be8b75..27bcf6f48cb 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -11,6 +11,7 @@ import { isPowerShellExecutableName } from '../powershell-osc133-bootstrap' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { getPosixPrimeAgentShellWrapper } from '../pty/prime-agent-shell-wrapper' import { getFishShellReadyInitCommand, getZshEnvTemplate, @@ -122,6 +123,7 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} +${getPosixPrimeAgentShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent @@ -243,6 +245,7 @@ if [[ ! -o login ]]; then [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} + ${getPosixPrimeAgentShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" fi __orca_osc133_precmd() { @@ -306,6 +309,7 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} +${getPosixPrimeAgentShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" ${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)} ${getZshFinalZdotdirRestoreBlock()} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 010f35f436e..d3ec26f91a6 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -56,6 +56,7 @@ const { clearAgentHookPaneStateMock, registerPaneKeyAliasMock, piBuildPtyEnvMock, + piBuildStatusOnlyPtyEnvMock, piClearPtyMock, isPwshAvailableMock, wslUncDirectoryExistsAsyncMock, @@ -94,6 +95,7 @@ const { clearAgentHookPaneStateMock: vi.fn(), registerPaneKeyAliasMock: vi.fn(), piBuildPtyEnvMock: vi.fn(), + piBuildStatusOnlyPtyEnvMock: vi.fn(), piClearPtyMock: vi.fn(), trackMock: vi.fn(), classifyErrorMock: vi.fn(), @@ -178,6 +180,7 @@ vi.mock('../agent-hooks/server', () => ({ vi.mock('../pi/titlebar-extension-service', () => ({ piTitlebarExtensionService: { buildPtyEnv: piBuildPtyEnvMock, + buildStatusOnlyPtyEnv: piBuildStatusOnlyPtyEnvMock, clearPty: piClearPtyMock } })) @@ -341,6 +344,7 @@ describe('registerPtyHandlers', () => { const savedOrcaOmpStatusExtension = process.env.ORCA_OMP_STATUS_EXTENSION const savedPrimeAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR const savedOrcaPrimeAgentSourceDir = process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + const savedOrcaPrimeAgentStatusExtension = process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION const savedOrcaClaudeAgentStatusSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS const savedProcessPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const savedDisableMacosLoginShell = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL @@ -368,6 +372,7 @@ describe('registerPtyHandlers', () => { delete process.env.ORCA_OMP_STATUS_EXTENSION delete process.env.PRIME_AGENT_CODING_AGENT_DIR delete process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION handlers.clear() handleMock.mockReset() onMock.mockReset() @@ -390,6 +395,7 @@ describe('registerPtyHandlers', () => { clearAgentHookPaneStateMock.mockReset() registerPaneKeyAliasMock.mockReset() piBuildPtyEnvMock.mockReset() + piBuildStatusOnlyPtyEnvMock.mockReset() piClearPtyMock.mockReset() isPwshAvailableMock.mockReset() wslUncDirectoryExistsAsyncMock.mockReset() @@ -491,6 +497,10 @@ describe('registerPtyHandlers', () => { } } ) + piBuildStatusOnlyPtyEnvMock.mockReturnValue({ + ORCA_PRIME_AGENT_STATUS_EXTENSION: + 'C:\\Users\\test\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts' + }) isPwshAvailableMock.mockReturnValue(false) spawnMock.mockReturnValue({ onData: vi.fn(() => makeDisposable()), @@ -596,6 +606,11 @@ describe('registerPtyHandlers', () => { } else { delete process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR } + if (savedOrcaPrimeAgentStatusExtension !== undefined) { + process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION = savedOrcaPrimeAgentStatusExtension + } else { + delete process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION + } if (savedOrcaClaudeAgentStatusSettings === undefined) { delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS } else { @@ -4206,7 +4221,7 @@ describe('registerPtyHandlers', () => { }) }) - it('does not materialize Windows-host Prime extensions for a WSL launch', async () => { + it('bridges a host-managed Prime status extension into WSL without redirecting Prime home', async () => { await withWin32Platform(async () => { const env = await daemonSpawnAndGetEnv( { PRIME_AGENT_CODING_AGENT_DIR: 'C:\\Users\\test\\.prime\\agent' }, @@ -4217,11 +4232,30 @@ describe('registerPtyHandlers', () => { ) expect(piBuildPtyEnvMock).not.toHaveBeenCalled() + expect(piBuildStatusOnlyPtyEnvMock).toHaveBeenCalledWith('prime-agent') expect(env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR).toBeUndefined() + expect(env.ORCA_PRIME_AGENT_STATUS_EXTENSION).toBe( + 'C:\\Users\\test\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts' + ) + expect(env.ORCA_WSL_HOOK_INSTANCE).toBe('port5678') expect(env.PRIME_AGENT_CODING_AGENT_DIR).toBe('C:\\Users\\test\\.prime\\agent') }) }) + it('prepares the Prime bridge for a typed launch in a bare WSL shell', async () => { + await withWin32Platform(async () => { + const env = await daemonSpawnAndGetEnv({}, undefined, undefined, undefined, { + shellOverride: 'wsl.exe' + }) + + expect(piBuildStatusOnlyPtyEnvMock).toHaveBeenCalledWith('prime-agent') + expect(env.ORCA_PRIME_AGENT_STATUS_EXTENSION).toContain( + 'prime-agent-managed-status-extension' + ) + expect(env.PRIME_AGENT_CODING_AGENT_DIR).toBeUndefined() + }) + }) + it('points OPENCODE_CONFIG_DIR at the guest overlay when the WSL relay reports it', async () => { const guestDir = '/home/jin/.orca-relay/opencode-overlays/abc' const spy = vi.spyOn(wslHookRelayManager, 'getOpenCodeOverlayDir').mockReturnValue(guestDir) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 3ff4e36fe1d..1ca019f6f0f 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1513,6 +1513,7 @@ function clearPiAgentShadowEnv(baseEnv: Record, kind: PiAgentKin } if (kind === 'prime-agent') { delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION return } delete baseEnv.ORCA_PI_CODING_AGENT_DIR @@ -1544,6 +1545,11 @@ function exposePiManagedExtensionEnv( } else { delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR } + if (managedEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION) { + baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION = managedEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION + } else { + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION + } return } delete baseEnv.ORCA_PI_CODING_AGENT_DIR @@ -1686,6 +1692,8 @@ export function buildPtyHostEnv( }) const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand + const shouldPreparePrimeStatus = + piAgentKind === 'prime-agent' || (opts.isWsl && !hasLaunchCommand) // Why: source shadows are agent-scoped; trusting the other kind's source reintroduces Pi/OMP extension-state shadowing. const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi') const preexistingOmpAgentDir = @@ -1744,6 +1752,12 @@ export function buildPtyHostEnv( if (opts.isWsl === true) { // Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one. const distro = opts.wslDistro ?? null + const hookInstance = wslHookRelayManager.getInstanceKey() + if (hookInstance) { + baseEnv.ORCA_WSL_HOOK_INSTANCE = hookInstance + } else { + delete baseEnv.ORCA_WSL_HOOK_INSTANCE + } wslHookRelayManager.ensureForDistro(distro) const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) if (guestEndpoint) { @@ -1790,13 +1804,14 @@ export function buildPtyHostEnv( exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv) } - if (piAgentKind === 'prime-agent' && !opts.isWsl) { - const primeEnv = piTitlebarExtensionService.buildPtyEnv( - id, - preexistingPrimeAgentDir, - 'prime-agent', - { materializeDefaultHome: explicitPiAgentKind === 'prime-agent' } - ) + if (shouldPreparePrimeStatus) { + // Why: the Windows host cannot safely resolve or write Prime's guest + // config root; WSL loads the host-managed status file explicitly. + const primeEnv = opts.isWsl + ? piTitlebarExtensionService.buildStatusOnlyPtyEnv('prime-agent') + : piTitlebarExtensionService.buildPtyEnv(id, preexistingPrimeAgentDir, 'prime-agent', { + materializeDefaultHome: explicitPiAgentKind === 'prime-agent' + }) Object.assign(baseEnv, primeEnv) exposePiManagedExtensionEnv(baseEnv, 'prime-agent', primeEnv) } @@ -1814,6 +1829,7 @@ export function buildPtyHostEnv( }) delete baseEnv.ORCA_OMP_STATUS_EXTENSION delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION } // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index ee051e938d1..b735cd60198 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -30,6 +30,7 @@ type Harness = { fsMock: { existsSync: ReturnType readFileSync: ReturnType + statSync: ReturnType } handlers: Record processEnv: Record @@ -62,6 +63,7 @@ function createHarness(args: { argv?: string[] existsSync?: (path: string) => boolean readFileSync?: (path: string, encoding: string) => string + statSync?: (path: string) => { mtimeMs: number; size: number; ino: number } fetchImpl?: (...params: Parameters) => Promise }): Harness { const fetchMock = vi.fn( @@ -86,6 +88,12 @@ function createHarness(args: { const fsMock = { existsSync: vi.fn(args.existsSync ?? (() => false)), + statSync: vi.fn( + args.statSync ?? + ((path: string) => { + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }) + ), readFileSync: vi.fn( args.readFileSync ?? ((path: string) => { @@ -562,6 +570,31 @@ describe('getPiAgentStatusExtensionSource', () => { ) }) + it('uses current Windows coordinates when a same-token guest endpoint is stale', async () => { + const endpointPath = '/home/u/.orca-wsl/agent-hooks/instance-test/endpoint.env' + const harness = createHarness({ + kind: 'prime-agent', + env: { WSL_DISTRO_NAME: 'Ubuntu', ORCA_AGENT_HOOK_ENDPOINT: endpointPath }, + existsSync: (path) => path === '/mnt/c/Windows/System32/curl.exe', + statSync: () => ({ mtimeMs: 1, size: 80, ino: 1 }), + readFileSync: (path) => { + if (path === endpointPath) { + return 'ORCA_AGENT_HOOK_PORT=9999\nORCA_AGENT_HOOK_TOKEN=token-1\n' + } + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }, + fetchImpl: vi.fn(async () => { + throw new Error('stale guest relay') + }) + }) + + await harness.callHook('agent_start') + + expect(harness.fetchMock.mock.calls[0]?.[0]).toBe('http://127.0.0.1:9999/hook/prime-agent') + await vi.waitFor(() => expect(harness.spawnMock).toHaveBeenCalledTimes(1)) + expect(harness.spawnMock.mock.calls[0]?.[1]).toContain('http://127.0.0.1:4321/hook/prime-agent') + }) + it('probes WSL evidence and the curl path once per process', async () => { const harness = createHarness({ kind: 'omp', diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index ff2240531ce..6adfdf23fbc 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -13,6 +13,7 @@ import type { PiAgentKind } from '../../shared/pi-agent-kind' import { getPiAgentStatusHandlerSourceLines } from './agent-status-handler-source' import { getPiAgentStatusRuntimeDetectionSourceLines } from './agent-status-runtime-detection-source' +import { getPiAgentStatusWslCurlSourceLines } from './agent-status-wsl-curl-source' export const ORCA_PI_AGENT_STATUS_EXTENSION_FILE = 'orca-agent-status.ts' @@ -228,92 +229,13 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' // Why: status reporting must never fail the pi run just because Orca', ' // is unavailable or the loopback request failed (e.g. Orca restart).', ' if (!isWslRuntime()) return', - ' postViaWindowsCurl(url, coords, body)', + ' postViaWindowsCurl(body, ompRuntime)', ' } finally {', ' if (timeout) clearTimeout(timeout)', ' }', '}', '', - '// Why: WSL-ness and curl.exe presence cannot change within a process', - '// lifetime; re-probing /proc and /mnt/c on every failed event would add', - '// filesystem work to the per-event path.', - 'let cachedIsWslRuntime: boolean | null = null', - 'let cachedWindowsCurlPath: string | null | undefined', - '', - 'function isWslRuntime(): boolean {', - ' if (cachedIsWslRuntime !== null) return cachedIsWslRuntime', - ' cachedIsWslRuntime = detectWslRuntime()', - ' return cachedIsWslRuntime', - '}', - '', - 'function detectWslRuntime(): boolean {', - ' if (process.env.WSL_DISTRO_NAME) return true', - ' try {', - " const fs = require('fs')", - " for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {", - ' try {', - " const contents = String(fs.readFileSync(path, 'utf8'))", - ' if (/microsoft|wsl/i.test(contents)) return true', - ' } catch {', - ' // Why: probe the next runtime hint when a proc file is absent or unreadable.', - ' }', - ' }', - ' } catch {', - ' return false', - ' }', - ' return false', - '}', - '', - 'function resolveWindowsCurlPath(): string | null {', - ' if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath', - ' try {', - " const fs = require('fs')", - " const curlPath = '/mnt/c/Windows/System32/curl.exe'", - ' cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null', - ' } catch {', - ' cachedWindowsCurlPath = null', - ' }', - ' return cachedWindowsCurlPath', - '}', - '', - '// Why: WSL loopback is not the Windows loopback, so a WSL-side POST cannot', - '// reach Orca. curl.exe runs on the Windows side, where 127.0.0.1 IS the', - '// listener Orca binds. Fire-and-forget: blocking on the spawn would stall', - '// the pi event loop (and the TUI) on every hook event.', - 'function postViaWindowsCurl(url: string, coords: { token: string }, body: string): void {', - ' const curlPath = resolveWindowsCurlPath()', - ' if (!curlPath) return', - ' try {', - " const { spawn } = require('child_process')", - ' const child = spawn(', - ' curlPath,', - ' [', - " '-sS',", - ' // Why: the spawn is detached from the event loop, so these bounds', - ' // size a background process, not TUI latency. WSL->Win32 interop', - ' // connects can exceed 0.5s on loaded machines (observed 3/3 drops', - ' // to a healthy listener); size for delivery, not snappiness.', - " '--connect-timeout', '3',", - " '--max-time', '10',", - " '--noproxy', '127.0.0.1',", - " '-o', 'NUL',", - " '-X', 'POST',", - " '-H', 'Content-Type: application/json',", - " '-H', `X-Orca-Agent-Hook-Token: ${coords.token}`,", - " '--data-binary', '@-',", - ' url', - ' ],', - " { stdio: ['pipe', 'ignore', 'ignore'] }", - ' )', - " child.on('error', () => {})", - " child.stdin.on('error', () => {})", - ' child.stdin.end(body)', - ' } catch {', - ' // Why: the bridge is best-effort; a failed spawn must not surface', - ' // inside the pi TUI.', - ' }', - '}', - '', + ...getPiAgentStatusWslCurlSourceLines(), ...getPiAgentStatusHandlerSourceLines(kind) ].join('\n') } diff --git a/src/main/pi/agent-status-wsl-curl-source.ts b/src/main/pi/agent-status-wsl-curl-source.ts new file mode 100644 index 00000000000..558e25a49db --- /dev/null +++ b/src/main/pi/agent-status-wsl-curl-source.ts @@ -0,0 +1,81 @@ +export function getPiAgentStatusWslCurlSourceLines(): string[] { + return [ + '// Why: WSL-ness and curl.exe presence cannot change within a process', + '// lifetime; re-probing /proc and /mnt/c on every failed event would add', + '// filesystem work to the per-event path.', + 'let cachedIsWslRuntime: boolean | null = null', + 'let cachedWindowsCurlPath: string | null | undefined', + '', + 'function isWslRuntime(): boolean {', + ' if (cachedIsWslRuntime !== null) return cachedIsWslRuntime', + ' cachedIsWslRuntime = detectWslRuntime()', + ' return cachedIsWslRuntime', + '}', + '', + 'function detectWslRuntime(): boolean {', + ' if (process.env.WSL_DISTRO_NAME) return true', + ' try {', + " const fs = require('fs')", + " for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {", + ' try {', + " const contents = String(fs.readFileSync(path, 'utf8'))", + ' if (/microsoft|wsl/i.test(contents)) return true', + ' } catch {', + ' // Why: probe the next runtime hint when a proc file is absent or unreadable.', + ' }', + ' }', + ' } catch {', + ' return false', + ' }', + ' return false', + '}', + '', + 'function resolveWindowsCurlPath(): string | null {', + ' if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath', + ' try {', + " const fs = require('fs')", + " const curlPath = '/mnt/c/Windows/System32/curl.exe'", + ' cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null', + ' } catch {', + ' cachedWindowsCurlPath = null', + ' }', + ' return cachedWindowsCurlPath', + '}', + '', + '// Why: WSL loopback is not the Windows loopback, so use curl.exe on the host.', + 'function postViaWindowsCurl(body: string, ompRuntime: boolean): void {', + ' const curlPath = resolveWindowsCurlPath()', + ' const windowsPort = process.env.ORCA_AGENT_HOOK_PORT', + ' const windowsToken = process.env.ORCA_AGENT_HOOK_TOKEN', + ' if (!curlPath || !windowsPort || !windowsToken) return', + ' // Why: a stale guest endpoint must fall back to current host coordinates.', + ' const windowsUrl = `http://127.0.0.1:${windowsPort}${resolveHookPath(ompRuntime)}`', + ' try {', + " const { spawn } = require('child_process')", + ' const child = spawn(', + ' curlPath,', + ' [', + " '-sS',", + ' // Why: detached delivery may take seconds under loaded WSL interop.', + " '--connect-timeout', '3',", + " '--max-time', '10',", + " '--noproxy', '127.0.0.1',", + " '-o', 'NUL',", + " '-X', 'POST',", + " '-H', 'Content-Type: application/json',", + " '-H', `X-Orca-Agent-Hook-Token: ${windowsToken}`,", + " '--data-binary', '@-',", + ' windowsUrl', + ' ],', + " { stdio: ['pipe', 'ignore', 'ignore'] }", + ' )', + " child.on('error', () => {})", + " child.stdin.on('error', () => {})", + ' child.stdin.end(body)', + ' } catch {', + ' // Why: status delivery must not surface inside the agent TUI.', + ' }', + '}', + '' + ] +} diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index e197a4a0c3a..485ea71cafa 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -94,6 +94,10 @@ describe('PiTitlebarExtensionService', () => { rmSync(join(userDataDir, 'pi-agent-overlays'), { recursive: true, force: true }) rmSync(join(userDataDir, 'omp-agent-overlays'), { recursive: true, force: true }) rmSync(join(userDataDir, 'omp-managed-status-extension'), { recursive: true, force: true }) + rmSync(join(userDataDir, 'prime-agent-managed-status-extension'), { + recursive: true, + force: true + }) }) function expectPiHomeIntact(): void { @@ -181,6 +185,21 @@ describe('PiTitlebarExtensionService', () => { expectPiHomeIntact() }) + it('writes a Prime status-only extension outside the guest-owned config dir', () => { + const svc = new PiTitlebarExtensionService() + const env = svc.buildStatusOnlyPtyEnv('prime-agent') + const statusPath = join( + userDataDir, + 'prime-agent-managed-status-extension', + 'orca-agent-status.ts' + ) + + expect(env).toEqual({ ORCA_PRIME_AGENT_STATUS_EXTENSION: statusPath }) + expect(readFileSync(statusPath, 'utf8')).toContain('/hook/prime-agent') + expect(readFileSync(statusPath, 'utf8')).not.toContain("return '/hook/omp'") + expectPiHomeIntact() + }) + it('uses the same source dir for multiple PTYs with the same Pi dir', () => { const svc = new PiTitlebarExtensionService() const firstEnv = svc.buildPtyEnv('pty-shared-1', piHome, 'pi') diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 5e728fa0274..59c0f44797f 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -27,7 +27,10 @@ export const isSafeDescendCandidate = sharedIsSafeDescendCandidate const PI_AGENT_SUBDIR = 'agent' const ORCA_MANAGED_EXTENSION_MARKER = '@orca-managed-pi-extension' -const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' +const MANAGED_STATUS_EXTENSION_DIR: Record<'omp' | 'prime-agent', string> = { + omp: 'omp-managed-status-extension', + 'prime-agent': 'prime-agent-managed-status-extension' +} type ManagedExtensionWriteResult = 'written' | 'skipped-user-owned' | 'failed' @@ -121,8 +124,11 @@ export class PiTitlebarExtensionService { } } - private writeOmpFallbackStatusExtension(source: string): string | undefined { - const fallbackDir = join(app.getPath('userData'), OMP_MANAGED_STATUS_EXTENSION_DIR) + private writeFallbackStatusExtension( + source: string, + kind: keyof typeof MANAGED_STATUS_EXTENSION_DIR + ): string | undefined { + const fallbackDir = join(app.getPath('userData'), MANAGED_STATUS_EXTENSION_DIR[kind]) try { mkdirSync(fallbackDir, { recursive: true }) } catch { @@ -165,7 +171,7 @@ export class PiTitlebarExtensionService { statusResult === 'written' ? statusExtensionPath : kind === 'omp' - ? this.writeOmpFallbackStatusExtension(statusSource) + ? this.writeFallbackStatusExtension(statusSource, 'omp') : undefined } } @@ -195,7 +201,7 @@ export class PiTitlebarExtensionService { if (!existsSync(sourceAgentDir) && !materializeDefaultHome) { if (kind === 'omp') { const statusSource = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind)) - const statusExtensionPath = this.writeOmpFallbackStatusExtension(statusSource) + const statusExtensionPath = this.writeFallbackStatusExtension(statusSource, 'omp') return statusExtensionPath ? { ORCA_OMP_STATUS_EXTENSION: statusExtensionPath } : {} } return {} @@ -220,6 +226,12 @@ export class PiTitlebarExtensionService { return env } + buildStatusOnlyPtyEnv(kind: 'prime-agent'): Record { + const source = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind)) + const statusExtensionPath = this.writeFallbackStatusExtension(source, kind) + return statusExtensionPath ? { ORCA_PRIME_AGENT_STATUS_EXTENSION: statusExtensionPath } : {} + } + clearPty(ptyId: string): void { // Why: PTY teardown doesn't know which kind was launched (the daemon // exit path discards the launch command). Sweep both old PTY-scoped diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index 1cb10ad7f14..d9034a5cc24 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -526,6 +526,8 @@ describePosix('local PTY shell-ready launch config', () => { '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0' const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"' + const primeWrapperLine = + 'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"' expect(zshrc).toContain(restoreLine) expect(zlogin).toContain(restoreLine) expect(bashRc).toContain(restoreLine) @@ -547,6 +549,9 @@ describePosix('local PTY shell-ready launch config', () => { expect(zshrc).toContain(ompWrapperLine) expect(zlogin).toContain(ompWrapperLine) expect(bashRc).toContain(ompWrapperLine) + expect(zshrc).toContain(primeWrapperLine) + expect(zlogin).toContain(primeWrapperLine) + expect(bashRc).toContain(primeWrapperLine) }) // Why: issue #2422 — without OSC 133 C/D markers, bash sessions kept the worktree spinner "working" ~30min after the agent exited. diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 18052dfa670..63885e23579 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -15,6 +15,7 @@ import { isPowerShellExecutableName } from '../powershell-osc133-bootstrap' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { getPosixPrimeAgentShellWrapper } from '../pty/prime-agent-shell-wrapper' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' import { getFishShellReadyInitCommand, @@ -129,6 +130,7 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} +${getPosixPrimeAgentShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent @@ -245,6 +247,7 @@ if [[ ! -o login ]]; then [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} + ${getPosixPrimeAgentShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" fi @@ -305,6 +308,7 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} +${getPosixPrimeAgentShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" ${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)} ${getZshFinalZdotdirRestoreBlock()} diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 39cb81ee9b5..53a83e6638d 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -280,13 +280,16 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(existsSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'))).toBe(true) expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true) - // Why: the point of materializing wrappers for WSL is that a typed `omp` - // picks up Orca's status extension; pin that shim end to end. + // Why: typed agents need the host-managed status extension in WSL. const bashRcfile = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') const zshLogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') for (const wrapperFile of [bashRcfile, zshLogin]) { expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"') expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }') + expect(wrapperFile).toContain( + 'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"' + ) + expect(wrapperFile).toContain('prime-agent() { __orca_prime_agent "$@"; }') } }) diff --git a/src/main/pty/prime-agent-shell-wrapper.test.ts b/src/main/pty/prime-agent-shell-wrapper.test.ts new file mode 100644 index 00000000000..1ad1fe774cb --- /dev/null +++ b/src/main/pty/prime-agent-shell-wrapper.test.ts @@ -0,0 +1,244 @@ +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getPosixPrimeAgentShellWrapper } from './prime-agent-shell-wrapper' + +const describePosix = process.platform === 'win32' ? describe.skip : describe +const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0 +const itWithBash = hasBash ? it : it.skip +const tempDirs: string[] = [] + +function makeFixture(): { + root: string + extensionPath: string + capturePath: string + endpointPath: string + primeHome: string + rcfile: string +} { + const root = mkdtempSync(join(tmpdir(), 'orca-prime-wrapper-')) + tempDirs.push(root) + const binDir = join(root, 'bin') + const primeHome = join(root, 'guest-prime-home') + const extensionPath = join(root, 'orca-agent-status.ts') + const capturePath = join(root, 'capture') + const endpointPath = join( + root, + '.orca-wsl', + 'agent-hooks', + 'instance-testinstance', + 'endpoint.env' + ) + const rcfile = join(root, 'rcfile') + mkdirSync(binDir) + mkdirSync(primeHome) + mkdirSync(dirname(endpointPath), { recursive: true }) + writeFileSync(extensionPath, 'export default {}') + writeFileSync(endpointPath, 'ORCA_AGENT_HOOK_PORT=4567\nORCA_AGENT_HOOK_TOKEN=current-token\n') + writeFileSync(rcfile, getPosixPrimeAgentShellWrapper()) + writeFileSync( + join(binDir, 'prime-agent'), + `#!/bin/sh +{ + printf 'HOME=%s\n' "$PRIME_AGENT_CODING_AGENT_DIR" + printf 'ENDPOINT=%s\n' "$ORCA_AGENT_HOOK_ENDPOINT" + i=0 + for arg in "$@"; do + i=$((i + 1)) + printf 'ARG%s=%s\n' "$i" "$arg" + done +} > "$ORCA_CAPTURE_FILE" +if [ "\${1:-}" = "config" ]; then + printf 'preserved\n' > "$PRIME_AGENT_CODING_AGENT_DIR/config.yml" +fi +`, + { mode: 0o755 } + ) + chmodSync(join(binDir, 'prime-agent'), 0o755) + return { root, extensionPath, capturePath, endpointPath, primeHome, rcfile } +} + +function runPrime( + fixture: ReturnType, + command: string, + extensionPath = fixture.extensionPath +): string { + const result = spawnSync('bash', ['--noprofile', '--rcfile', fixture.rcfile, '-ic', command], { + cwd: fixture.root, + env: { + ...process.env, + HOME: fixture.root, + PATH: `${join(fixture.root, 'bin')}:${process.env.PATH ?? ''}`, + PRIME_AGENT_CODING_AGENT_DIR: fixture.primeHome, + ORCA_PRIME_AGENT_STATUS_EXTENSION: extensionPath, + ORCA_WSL_HOOK_INSTANCE: 'testinstance', + ORCA_AGENT_HOOK_ENDPOINT: '/mnt/c/current/endpoint.cmd', + ORCA_AGENT_HOOK_PORT: '4567', + ORCA_AGENT_HOOK_TOKEN: 'current-token', + ORCA_CAPTURE_FILE: fixture.capturePath + }, + encoding: 'utf8' + }) + expect(result.status, result.stderr).toBe(0) + return readFileSync(fixture.capturePath, 'utf8') +} + +describePosix('Prime Agent shell wrapper', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + itWithBash('injects the managed extension into an interactive launch', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent ask') + + expect(capture).toContain(`HOME=${fixture.primeHome}`) + expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`) + expect(capture).toContain('ARG1=--extension') + expect(capture).toContain(`ARG2=${fixture.extensionPath}`) + expect(capture).toContain('ARG3=ask') + }) + + itWithBash('preserves config subcommands and the guest Prime home', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent config') + + expect(capture).toContain(`HOME=${fixture.primeHome}`) + expect(capture).toContain('ARG1=config') + expect(capture).not.toContain('ARG1=--extension') + expect(readFileSync(join(fixture.primeHome, 'config.yml'), 'utf8')).toBe('preserved\n') + }) + + itWithBash.each([ + 'agents', + 'doctor', + 'help', + 'list', + 'model', + 'package', + 'rename', + 'schedule', + 'send', + 'session', + 'shutdown', + 'status', + 'stop', + 'update' + ])('preserves the %s subcommand argv', (subcommand) => { + const fixture = makeFixture() + const capture = runPrime(fixture, `prime-agent ${subcommand}`) + + expect(capture).toContain(`ARG1=${subcommand}`) + expect(capture).not.toContain('ARG1=--extension') + }) + + itWithBash.each(['stop', 'rename'])( + 'preserves --daemon-socket before the %s management command', + (subcommand) => { + const fixture = makeFixture() + const capture = runPrime( + fixture, + `prime-agent --daemon-socket /tmp/prime.sock ${subcommand} agent-123` + ) + + expect(capture).toContain('ARG1=--daemon-socket') + expect(capture).toContain('ARG2=/tmp/prime.sock') + expect(capture).toContain(`ARG3=${subcommand}`) + expect(capture).not.toContain(fixture.extensionPath) + } + ) + + itWithBash('injects the extension after the attach agent operand', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent attach agent-123 --follow') + + expect(capture).toContain('ARG1=attach') + expect(capture).toContain('ARG2=agent-123') + expect(capture).toContain('ARG3=--extension') + expect(capture).toContain(`ARG4=${fixture.extensionPath}`) + expect(capture).toContain('ARG5=--follow') + }) + + itWithBash('preserves attach help without inventing an agent operand', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent attach --help') + + expect(capture).toContain('ARG1=attach') + expect(capture).toContain('ARG2=--help') + expect(capture).not.toContain(fixture.extensionPath) + }) + + itWithBash('does not override an explicit user extension', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent --extension /tmp/user-extension.ts ask') + + expect(capture).toContain('ARG1=--extension') + expect(capture).toContain('ARG2=/tmp/user-extension.ts') + expect(capture).not.toContain(fixture.extensionPath) + }) + + itWithBash('does not add a second extension to attach', () => { + const fixture = makeFixture() + const capture = runPrime( + fixture, + 'prime-agent attach agent-123 --extension /tmp/user-extension.ts' + ) + + expect(capture).toContain('ARG1=attach') + expect(capture).toContain('ARG2=agent-123') + expect(capture).toContain('ARG3=--extension') + expect(capture).toContain('ARG4=/tmp/user-extension.ts') + expect(capture).not.toContain(fixture.extensionPath) + }) + + itWithBash('degrades to the original argv when the managed extension is unavailable', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent ask', join(fixture.root, 'missing.ts')) + + expect(capture).toContain('ARG1=ask') + expect(capture).not.toContain('ARG1=--extension') + }) + + itWithBash('keeps the stable guest endpoint path while its relay refreshes stale data', () => { + const fixture = makeFixture() + writeFileSync(fixture.endpointPath, 'ORCA_AGENT_HOOK_PORT=4567\nORCA_AGENT_HOOK_TOKEN=stale\n') + + const capture = runPrime(fixture, 'prime-agent ask') + + expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`) + expect(capture).toContain('ARG1=--extension') + }) + + itWithBash('uses the guest endpoint path when the relay binds a fallback port', () => { + const fixture = makeFixture() + writeFileSync( + fixture.endpointPath, + 'ORCA_AGENT_HOOK_PORT=9876\nORCA_AGENT_HOOK_TOKEN=current-token\n' + ) + + const capture = runPrime(fixture, 'prime-agent ask') + + expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`) + expect(capture).toContain('ARG1=--extension') + }) + + itWithBash('does not define wrapper helpers when the bridge env is absent', () => { + const fixture = makeFixture() + const result = spawnSync( + 'bash', + ['--noprofile', '--rcfile', fixture.rcfile, '-ic', 'type -t __orca_prime_agent'], + { + cwd: fixture.root, + env: { ...process.env, ORCA_PRIME_AGENT_STATUS_EXTENSION: '' }, + encoding: 'utf8' + } + ) + + expect(result.status).not.toBe(0) + expect(result.stdout.trim()).toBe('') + }) +}) diff --git a/src/main/pty/prime-agent-shell-wrapper.ts b/src/main/pty/prime-agent-shell-wrapper.ts new file mode 100644 index 00000000000..97e723c26f1 --- /dev/null +++ b/src/main/pty/prime-agent-shell-wrapper.ts @@ -0,0 +1,74 @@ +import { wslHookRelayEndpointFilePath } from '../../shared/wsl-hook-relay-contract' + +const PRIME_AGENT_SUBCOMMANDS = [ + 'agents', + 'config', + 'doctor', + 'help', + 'list', + 'model', + 'package', + 'rename', + 'schedule', + 'send', + 'session', + 'shutdown', + 'status', + 'stop', + 'update' +] as const + +export function getPosixPrimeAgentShellWrapper(): string { + const subcommands = PRIME_AGENT_SUBCOMMANDS.join('|') + const guestEndpointPath = wslHookRelayEndpointFilePath('${HOME%/}', '${ORCA_WSL_HOOK_INSTANCE}') + return `# Why: WSL cannot install into Prime's guest-owned config root from the +# Windows host, so pass Orca's status extension only to interactive launches. +if [[ -n "\${ORCA_PRIME_AGENT_STATUS_EXTENSION:-}" ]]; then + __orca_prime_agent_should_skip_extension() { + if [[ "\${1:-}" == "--daemon-socket" && ( "\${3:-}" == "stop" || "\${3:-}" == "rename" ) ]]; then + return 0 + fi + if [[ "\${1:-}" == --daemon-socket=* && ( "\${2:-}" == "stop" || "\${2:-}" == "rename" ) ]]; then + return 0 + fi + case "\${1:-}" in + help|--help|-h|--version|-v|--extension|--extension=*) return 0 ;; + ${subcommands}) return 0 ;; + esac + return 1 + } + __orca_prime_agent_has_explicit_extension() { + local __orca_arg + for __orca_arg in "$@"; do + case "$__orca_arg" in + --extension|--extension=*) return 0 ;; + esac + done + return 1 + } + __orca_prime_agent() { + if [[ "\${1:-}" == "attach" && ( -z "\${2:-}" || "\${2:-}" == -* ) ]]; then + command prime-agent "$@" + return + fi + if ! __orca_prime_agent_should_skip_extension "$@" && ! __orca_prime_agent_has_explicit_extension "$@" && [[ -f "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" ]]; then + local __orca_guest_endpoint="${guestEndpointPath}" + if [[ -n "\${HOME:-}" && -n "\${ORCA_WSL_HOOK_INSTANCE:-}" ]]; then + local ORCA_AGENT_HOOK_ENDPOINT="$__orca_guest_endpoint" + export ORCA_AGENT_HOOK_ENDPOINT + fi + if [[ "\${1:-}" == "attach" && -n "\${2:-}" && "\${2:-}" != -* ]]; then + local __orca_attach_agent="$2" + shift 2 + command prime-agent attach "$__orca_attach_agent" --extension "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@" + else + command prime-agent --extension "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@" + fi + else + command prime-agent "$@" + fi + } + prime-agent() { __orca_prime_agent "$@"; } +fi +` +} diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 15c6b43cf44..c27655ba7e4 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -48,6 +48,8 @@ describe('addOrcaWslInteropEnv', () => { ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', ORCA_CLI_COMMAND: 'orca-ide', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', + ORCA_PRIME_AGENT_STATUS_EXTENSION: + 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts', ORCA_PANE_KEY: 'tab-1:leaf-1', ORCA_TAB_ID: 'tab-1', ORCA_WORKTREE_ID: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', @@ -56,6 +58,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_AGENT_HOOK_TOKEN: 'token', ORCA_AGENT_HOOK_ENV: 'dev', ORCA_AGENT_HOOK_VERSION: '1', + ORCA_WSL_HOOK_INSTANCE: 'testinstance', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu' @@ -67,6 +70,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') + expect(env.WSLENV).toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION/p') expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') expect(env.WSLENV).toContain('ORCA_TAB_ID/u') expect(env.WSLENV).toContain('ORCA_WORKTREE_ID/u') @@ -75,6 +79,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u') expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u') expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u') + expect(env.WSLENV).toContain('ORCA_WSL_HOOK_INSTANCE/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 40f3b957249..5e90eaa6b4a 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -96,6 +96,7 @@ export function addOrcaWslInteropEnv(env: Record): void { 'ORCA_WSL_HOOK_INSTANCE/u', 'ORCA_OMP_SOURCE_AGENT_DIR/p', 'ORCA_OMP_STATUS_EXTENSION/p', + 'ORCA_PRIME_AGENT_STATUS_EXTENSION/p', ...worktreeSetupWslenvEntries(env) ] applyWslenvPassthrough(env, passthroughEntries) From 016df33f00ea3588c1b945fcb2ec985ba8d8ac79 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:14:17 -0700 Subject: [PATCH 031/271] fix(github): complete PR reactions for CodeRabbit reviews (#13456) * feat(github): add PR comment reaction controls * feat(github): add full PR comment reaction picker * fix(github): cover all reactable PR comment paths * fix(github): reconcile comment reactions with main * fix(github): preserve focus on failed reaction removal --- src/main/github/client.test.ts | 54 +++++++++++ src/main/github/client.ts | 38 +++++++- .../components/github/CommentReactions.tsx | 62 +++++++++--- .../components/right-sidebar/ChecksPanel.tsx | 11 ++- .../right-sidebar/checks-panel-content.tsx | 8 +- .../helpers/pr-comments-sidebar-fixture.ts | 3 +- tests/e2e/pr-comments-sidebar-cards.spec.ts | 95 ++++++++++++++++--- 7 files changed, 237 insertions(+), 34 deletions(-) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index eef3aace0e7..a33b9b38d20 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -4044,6 +4044,60 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(noteRateLimitSpendMock).not.toHaveBeenCalledWith('graphql') }) + it('maps review summary reaction subjects from GraphQL', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { nodes: [] }, + comments: { nodes: [] }, + reviews: { + nodes: [ + { + id: 'PRR_44', + databaseId: 44, + author: { + __typename: 'Bot', + login: 'coderabbitai', + avatarUrl: 'https://avatar' + }, + body: 'Automated review summary', + createdAt: '2026-04-01T00:00:00Z', + url: 'https://github.com/acme/widgets/pull/7#pullrequestreview-44', + reactionGroups: [ + { + content: 'ROCKET', + viewerHasReacted: true, + reactors: { totalCount: 2 } + } + ] + } + ] + } + } + } + } + }) + }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) + + await expect(getPRComments('/repo-root', 7)).resolves.toEqual([ + expect.objectContaining({ + id: 44, + reactionSubjectId: 'PRR_44', + isBot: true, + reactions: [{ content: 'rocket', count: 2, viewerHasReacted: true }] + }) + ]) + expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([expect.stringContaining('reviews(first: 100)')]) + ) + }) + it('uses explicit PR repo for comments when a fork PR is discovered', async () => { rateLimitGuardMock.mockImplementation(((bucket: string) => bucket === 'graphql' diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 46ea1180e24..7518c9d3581 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -4157,6 +4157,23 @@ query($owner: String!, $repo: String!, $pr: Int!) { } } } + reviews(first: 100) { + nodes { + id + databaseId + author { __typename login avatarUrl(size: 48) } + body + createdAt + url + reactionGroups { + content + viewerHasReacted + reactors { + totalCount + } + } + } + } } } }` @@ -4284,6 +4301,7 @@ export async function getPRComments( url: string reactionGroups?: GitHubGraphQLReactionGroup[] | null } + let graphQLReviewSummaries: PRComment[] | undefined const reviewComments: PRComment[] = [] if (threadsResult.status === 'fulfilled' && threadsResult.value) { const threadsData = JSON.parse(threadsResult.value.stdout) as { @@ -4292,6 +4310,7 @@ export async function getPRComments( pullRequest: { reviewThreads: { nodes: GQLThread[] } comments?: { nodes: GQLIssueComment[] } + reviews?: { nodes: GQLIssueComment[] } } } } @@ -4313,6 +4332,21 @@ export async function getPRComments( if (graphQLIssueComments.length > 0) { issueComments = graphQLIssueComments } + graphQLReviewSummaries = (pullRequest.reviews?.nodes ?? []) + .filter((review) => review.body?.trim()) + .map( + (review): PRComment => ({ + id: review.databaseId, + author: review.author?.login ?? 'ghost', + authorAvatarUrl: review.author?.avatarUrl ?? '', + body: review.body, + createdAt: review.createdAt, + url: review.url, + isBot: review.author?.__typename === 'Bot', + reactionSubjectId: review.id, + reactions: mapGraphQLReactionGroups(review.reactionGroups) + }) + ) const threads = pullRequest.reviewThreads.nodes for (const thread of threads) { @@ -4353,7 +4387,9 @@ export async function getPRComments( html_url: string } let reviewSummaries: PRComment[] = [] - if (reviewsResult.status === 'fulfilled') { + if (graphQLReviewSummaries) { + reviewSummaries = graphQLReviewSummaries + } else if (reviewsResult.status === 'fulfilled') { reviewSummaries = (JSON.parse(reviewsResult.value.stdout) as RESTReview[]) .filter((r) => r.body?.trim()) .map( diff --git a/src/renderer/src/components/github/CommentReactions.tsx b/src/renderer/src/components/github/CommentReactions.tsx index bbdca0fbe3d..21aa4ac2316 100644 --- a/src/renderer/src/components/github/CommentReactions.tsx +++ b/src/renderer/src/components/github/CommentReactions.tsx @@ -37,9 +37,15 @@ export function CommentReactions({ }: { reactions?: GitHubReaction[] className?: string - onReactionChange?: (content: GitHubReactionContent, reacted: boolean) => Promise | void + onReactionChange?: ( + content: GitHubReactionContent, + reacted: boolean + ) => Promise | boolean }): React.JSX.Element | null { const visibleReactions = (reactions ?? []).filter((reaction) => reaction.count > 0) + const addReactionButtonRef = React.useRef(null) + const pickerGroupRef = React.useRef(null) + const mutationPendingRef = React.useRef(false) const [open, setOpen] = React.useState(false) const [pendingContent, setPendingContent] = React.useState(null) if (visibleReactions.length === 0 && !onReactionChange) { @@ -48,16 +54,25 @@ export function CommentReactions({ const changeReaction = async ( content: GitHubReactionContent, - reacted: boolean + reacted: boolean, + closePicker: boolean, + focusTriggerAfterChange = false ): Promise => { - if (!onReactionChange || pendingContent) { + if (!onReactionChange || mutationPendingRef.current) { return } + mutationPendingRef.current = true setPendingContent(content) - setOpen(false) + if (focusTriggerAfterChange) { + addReactionButtonRef.current?.focus() + } try { - await onReactionChange(content, reacted) + const changed = await onReactionChange(content, reacted) + if (changed && closePicker) { + setOpen(false) + } } finally { + mutationPendingRef.current = false setPendingContent(null) } } @@ -76,7 +91,7 @@ export function CommentReactions({ type="button" variant="outline" size="xs" - disabled={pendingContent !== null} + aria-disabled={pendingContent !== null} className={cn( 'h-6 gap-1 rounded-full px-2 text-[12px] font-normal', reaction.viewerHasReacted && 'border-ring bg-accent text-accent-foreground' @@ -91,7 +106,14 @@ export function CommentReactions({ value2: reaction.count === 1 ? '' : 's' } )} - onClick={() => void changeReaction(reaction.content, !reaction.viewerHasReacted)} + onClick={() => + void changeReaction( + reaction.content, + !reaction.viewerHasReacted, + false, + Boolean(reaction.viewerHasReacted && reaction.count === 1) + ) + } > {reaction.count} @@ -121,10 +143,11 @@ export function CommentReactions({ diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 805a125d565..9fc66a78819 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -2945,10 +2945,14 @@ export default function ChecksPanel(): React.JSX.Element { ) const handleSetReaction = useCallback( - async (comment: PRComment, content: GitHubReactionContent, reacted: boolean): Promise => { + async ( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean + ): Promise => { const reactionSubjectId = comment.reactionSubjectId if (!repo || !prNumber || !pr?.prRepo || !reactionSubjectId) { - return + return false } const requestKey = checksPanelAsyncResultKey( prCacheKey, @@ -2968,7 +2972,7 @@ export default function ChecksPanel(): React.JSX.Element { { repoId: repo.id, prRepo: pr.prRepo } ) if (!isCurrentAsyncResult(requestKey) || ok) { - return + return ok } setComments((current) => restoreReactionOnSubject(current, reactionSubjectId, content, previousReaction) @@ -2979,6 +2983,7 @@ export default function ChecksPanel(): React.JSX.Element { 'Failed to update reaction.' ) ) + return false }, [branch, isCurrentAsyncResult, pr, prCacheKey, prNumber, repo, setPRCommentReaction] ) 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 dda1c3ee4e1..2b2b695b189 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -1756,7 +1756,7 @@ function CommentRow({ comment: PRComment, content: GitHubReactionContent, reacted: boolean - ) => Promise + ) => Promise onQueueForAgent?: () => void }): React.JSX.Element { const automated = isBotPRComment(comment, botAuthorOverrides) @@ -2070,7 +2070,7 @@ function PRCommentGroupView({ comment: PRComment, content: GitHubReactionContent, reacted: boolean - ) => Promise + ) => Promise onQueueForAgent?: () => void }): React.JSX.Element { // Reply targets a specific comment id so any comment in a thread — root or @@ -2219,7 +2219,7 @@ function ResolvedCommentGroupsSection({ comment: PRComment, content: GitHubReactionContent, reacted: boolean - ) => Promise + ) => Promise }): React.JSX.Element | null { if (groups.length === 0) { return null @@ -2344,7 +2344,7 @@ export function PRCommentsList({ comment: PRComment, content: GitHubReactionContent, reacted: boolean - ) => Promise + ) => Promise }): React.JSX.Element { const presentation = React.useMemo(() => getPRCommentPresentationClasses(), []) const [commentFilter, setCommentFilter] = useState('all') diff --git a/tests/e2e/helpers/pr-comments-sidebar-fixture.ts b/tests/e2e/helpers/pr-comments-sidebar-fixture.ts index b44aaa84c3c..3fc047ec1bf 100644 --- a/tests/e2e/helpers/pr-comments-sidebar-fixture.ts +++ b/tests/e2e/helpers/pr-comments-sidebar-fixture.ts @@ -10,7 +10,7 @@ export type PRCommentsSidebarSeed = { export const FIXTURE_COMMENTS: PRComment[] = [ { id: 101, - author: 'alice', + author: 'coderabbitai', authorAvatarUrl: '', body: 'Please update this handler before merge.', createdAt: '2026-05-14T10:00:00.000Z', @@ -18,6 +18,7 @@ export const FIXTURE_COMMENTS: PRComment[] = [ reactionSubjectId: 'PRRC_101', threadId: 'thread-open', path: 'src/handler.ts', + isBot: true, isResolved: false }, { diff --git a/tests/e2e/pr-comments-sidebar-cards.spec.ts b/tests/e2e/pr-comments-sidebar-cards.spec.ts index ee95b3f74cb..d0cfa4498c2 100644 --- a/tests/e2e/pr-comments-sidebar-cards.spec.ts +++ b/tests/e2e/pr-comments-sidebar-cards.spec.ts @@ -57,7 +57,7 @@ test.describe('PR comments sidebar cards view', () => { await expect(orcaPage.getByText('Needs review · 1')).toBeVisible() await expect(orcaPage.getByText('Please update this handler before merge.')).toBeVisible() - await expect(orcaPage.getByText('alice')).toBeVisible() + await expect(orcaPage.getByText('coderabbitai')).toBeVisible() await expect(orcaPage.getByText('LGTM on the overall approach.')).toBeVisible() const openThreadCard = orcaPage.getByTestId('pr-comment-group').filter({ @@ -75,7 +75,7 @@ test.describe('PR comments sidebar cards view', () => { 'Please update this handler before merge.', 'LGTM on the overall approach.' ) - await expectOpenTextNotShiftedLeft(openThreadCard, conversationCard, 'alice', 'bob') + await expectOpenTextNotShiftedLeft(openThreadCard, conversationCard, 'coderabbitai', 'bob') const resolvedTrigger = orcaPage.getByRole('button', { name: 'Resolved · 1' }) await expect(resolvedTrigger).toBeVisible() @@ -124,37 +124,106 @@ test.describe('PR comments sidebar cards view', () => { expect(positions[1]).toBeLessThan(positions[2]) }) - test('adds reactions to conversation and review-thread comments', async ({ orcaPage }) => { + test('adds reactions to conversation and review-thread comments', async ({ + orcaPage + }, testInfo) => { const { worktreeId } = await seedPRCommentsSidebarFixture(orcaPage) await openChecks(orcaPage, worktreeId) await expect(orcaPage.getByText('Needs review · 1')).toBeVisible({ timeout: 10_000 }) + const reviewThreadCard = orcaPage.getByTestId('pr-comment-group').filter({ + hasText: 'Please update this handler before merge.' + }) + const threadReactionButton = reviewThreadCard.getByRole('button', { name: 'Add reaction' }) + await orcaPage.screenshot({ path: testInfo.outputPath('reaction-before.png') }) + await threadReactionButton.click() + await expect(orcaPage.getByRole('group', { name: 'Add reaction' })).toBeFocused() + await orcaPage.waitForTimeout(300) + await orcaPage.screenshot({ path: testInfo.outputPath('reaction-picker.png') }) + await orcaPage.getByRole('button', { name: 'Add rocket reaction' }).click() + await expect(orcaPage.getByRole('group', { name: 'Add reaction' })).toBeHidden() + const selectedRocket = reviewThreadCard.getByRole('button', { name: '1 rocket reaction' }) + await expect(selectedRocket).toHaveAttribute('aria-pressed', 'true') + await selectedRocket.focus() + await orcaPage.waitForTimeout(300) + await orcaPage.screenshot({ path: testInfo.outputPath('reaction-after.png') }) + await selectedRocket.press('Enter') + await expect(selectedRocket).toHaveCount(0) + await expect(threadReactionButton).toBeFocused() + const conversationCard = orcaPage.getByTestId('pr-comment-group').filter({ hasText: 'LGTM on the overall approach.' }) const conversationReactionButton = conversationCard.getByRole('button', { name: 'Add reaction' }) - await conversationReactionButton.evaluate((element) => (element as HTMLElement).click()) - const heartReactionButton = orcaPage.getByRole('button', { name: 'Add heart reaction' }) + await conversationReactionButton.click() + const conversationPicker = orcaPage.getByRole('group', { name: 'Add reaction' }).last() + const heartReactionButton = conversationPicker.getByRole('button', { + name: 'Add heart reaction' + }) await expect(heartReactionButton).toBeVisible() await heartReactionButton.evaluate((element) => (element as HTMLElement).click()) await expect( conversationCard.getByRole('button', { name: '1 heart reaction' }) ).toHaveAttribute('aria-pressed', 'true') await expect(orcaPage.getByRole('button', { name: 'Add rocket reaction' })).toHaveCount(0) + }) + + test('keeps reaction focus while a remote mutation fails', async ({ orcaPage }) => { + const { worktreeId } = await seedPRCommentsSidebarFixture(orcaPage) + await openChecks(orcaPage, worktreeId) + await expect(orcaPage.getByText('Needs review · 1')).toBeVisible({ timeout: 10_000 }) + await orcaPage.evaluate(() => { + window.__store?.setState({ + setPRCommentReaction: async () => { + await new Promise((resolve) => window.setTimeout(resolve, 300)) + return false + } + }) + }) const reviewThreadCard = orcaPage.getByTestId('pr-comment-group').filter({ hasText: 'Please update this handler before merge.' }) - const threadReactionButton = reviewThreadCard.getByRole('button', { name: 'Add reaction' }) - await threadReactionButton.evaluate((element) => (element as HTMLElement).click()) - await orcaPage - .getByRole('button', { name: 'Add rocket reaction' }) - .evaluate((element) => (element as HTMLElement).click()) - await expect( - reviewThreadCard.getByRole('button', { name: '1 rocket reaction' }) - ).toHaveAttribute('aria-pressed', 'true') + const addReaction = reviewThreadCard.getByRole('button', { name: 'Add reaction' }) + await addReaction.focus() + await addReaction.press('Enter') + const picker = orcaPage.getByRole('group', { name: 'Add reaction' }) + await expect(picker).toBeFocused() + const rocket = picker.getByRole('button', { name: /rocket reaction/ }) + await rocket.focus() + await rocket.press('Enter') + await expect(rocket).toBeFocused() + await expect(rocket).toHaveAttribute('aria-disabled', 'true') + await rocket.press('Enter') + await expect(picker).toBeVisible() + await expect(rocket).toHaveAttribute('aria-disabled', 'false') + await expect(rocket).toBeFocused() + await expect(rocket).toHaveAccessibleName('Add rocket reaction') + + await orcaPage.evaluate(() => { + window.__store?.setState({ setPRCommentReaction: async () => true }) + }) + await rocket.press('Enter') + const selectedRocket = reviewThreadCard.getByRole('button', { name: '1 rocket reaction' }) + await expect(selectedRocket).toHaveAttribute('aria-pressed', 'true') + await orcaPage.evaluate(() => { + window.__store?.setState({ + setPRCommentReaction: async () => { + await new Promise((resolve) => window.setTimeout(resolve, 300)) + return false + } + }) + }) + await selectedRocket.focus() + await selectedRocket.press('Enter') + await expect(addReaction).toBeFocused() + await expect(addReaction).toHaveAttribute('aria-disabled', 'true') + await expect(selectedRocket).toHaveCount(0) + await expect(selectedRocket).toHaveCount(1) + await expect(addReaction).toHaveAttribute('aria-disabled', 'false') + await expect(addReaction).toBeFocused() }) test('queues an open thread for the agent from the visible row action and menu fallback', async ({ From 73efab98f7b4d9371748170925d7990d039c9daa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:26:28 -0700 Subject: [PATCH 032/271] perf(orchestration): keep drift Git off main thread (#13440) --- src/main/git/repo-remote-drift-real.test.ts | 63 ++++++++++++++++++++ src/main/git/repo-remote-drift.test.ts | 65 +++++++++++++++++++++ src/main/git/repo.ts | 23 +++++--- src/main/runtime/orca-runtime.test.ts | 41 ++++++++----- src/main/runtime/orca-runtime.ts | 20 ++++--- 5 files changed, 180 insertions(+), 32 deletions(-) create mode 100644 src/main/git/repo-remote-drift-real.test.ts create mode 100644 src/main/git/repo-remote-drift.test.ts diff --git a/src/main/git/repo-remote-drift-real.test.ts b/src/main/git/repo-remote-drift-real.test.ts new file mode 100644 index 00000000000..3ee2f7c45c9 --- /dev/null +++ b/src/main/git/repo-remote-drift-real.test.ts @@ -0,0 +1,63 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getRecentDriftSubjects, getRemoteDrift } from './repo' + +describe('remote drift real Git contract', () => { + const tempPaths: string[] = [] + + afterEach(() => { + for (const path of tempPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + }) + + it('preserves clean, diverged, and missing-ref results', async () => { + const repoPath = mkdtempSync(join(tmpdir(), 'orca-remote-drift-')) + tempPaths.push(repoPath) + const git = (...args: string[]): string => + execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' }) + + git('init', '--quiet') + git('config', 'user.name', 'Orca Test') + git('config', 'user.email', 'orca@example.test') + git('config', 'commit.gpgSign', 'false') + git('config', 'core.hooksPath', '.git/no-hooks') + writeFileSync(join(repoPath, 'fixture.txt'), 'base\n') + git('add', 'fixture.txt') + git('commit', '-m', 'base') + git('branch', '-M', 'fixture-base') + git('branch', 'feature') + + writeFileSync(join(repoPath, 'fixture.txt'), 'remote one\n') + git('commit', '-am', 'remote one') + writeFileSync(join(repoPath, 'fixture.txt'), 'remote two\n') + git('commit', '-am', 'remote two') + git('update-ref', 'refs/remotes/origin/main', 'HEAD') + + git('checkout', '--quiet', 'feature') + writeFileSync(join(repoPath, 'local.txt'), 'local one\n') + git('add', 'local.txt') + git('commit', '-m', 'local one') + + await expect(getRemoteDrift(repoPath, 'HEAD', 'origin/main')).resolves.toEqual({ + ahead: 1, + behind: 2 + }) + await expect(getRecentDriftSubjects(repoPath, 'HEAD', 'origin/main', 5)).resolves.toEqual([ + 'remote two', + 'remote one' + ]) + await expect(getRemoteDrift(repoPath, 'origin/main', 'origin/main')).resolves.toEqual({ + ahead: 0, + behind: 0 + }) + await expect( + getRecentDriftSubjects(repoPath, 'origin/main', 'origin/main', 5) + ).resolves.toEqual([]) + await expect(getRemoteDrift(repoPath, 'HEAD', 'missing-ref')).resolves.toBeNull() + await expect(getRecentDriftSubjects(repoPath, 'HEAD', 'missing-ref', 5)).resolves.toEqual([]) + }) +}) diff --git a/src/main/git/repo-remote-drift.test.ts b/src/main/git/repo-remote-drift.test.ts new file mode 100644 index 00000000000..3d0b7ef10e9 --- /dev/null +++ b/src/main/git/repo-remote-drift.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GitRunner from './runner' + +const { gitExecFileAsyncMock, gitExecFileSyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn() +})) + +vi.mock('./runner', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: gitExecFileSyncMock + } +}) + +import { getRecentDriftSubjects, getRemoteDrift } from './repo' + +describe('remote drift Git probes', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileSyncMock.mockReset() + }) + + it('parses drift asynchronously with the existing timeout and WSL routing', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '3\t4\n', stderr: '' }) + + await expect( + getRemoteDrift('C:\\repo', 'HEAD', 'origin/main', { wslDistro: 'Ubuntu' }) + ).resolves.toEqual({ ahead: 3, behind: 4 }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['rev-list', '--left-right', '--count', 'HEAD...origin/main'], + { cwd: 'C:\\repo', wslDistro: 'Ubuntu', timeout: 15_000 } + ) + expect(gitExecFileSyncMock).not.toHaveBeenCalled() + }) + + it('keeps malformed output and Git failures unknown', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'invalid\n', stderr: '' }) + await expect(getRemoteDrift('/repo', 'HEAD', 'origin/main')).resolves.toBeNull() + + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('git failed')) + await expect(getRemoteDrift('/repo', 'HEAD', 'origin/main')).resolves.toBeNull() + }) + + it('preserves subject order and degrades a log failure to an empty list', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'newest subject\n\nolder subject\n', + stderr: '' + }) + await expect(getRecentDriftSubjects('/repo', 'HEAD', 'origin/main', 5)).resolves.toEqual([ + 'newest subject', + 'older subject' + ]) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['log', '--format=%s', '-n', '5', 'HEAD..origin/main'], + { cwd: '/repo', timeout: 15_000 } + ) + + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('git failed')) + await expect(getRecentDriftSubjects('/repo', 'HEAD', 'origin/main', 5)).resolves.toEqual([]) + }) +}) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 05d218e39db..e609d90a981 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -535,17 +535,21 @@ export async function getBaseRefDefault( /** * Return { ahead, behind } (merge-base-symmetric delta) for localRef vs remoteRef, or null on failure. * ahead = commits on localRef not in remoteRef; behind = the reverse. Used by the stale-base dispatch guard (§3.1). + * Async because this runs before every orchestration dispatch on Electron main. */ -export function getRemoteDrift( +export async function getRemoteDrift( repoPath: string, localRef: string, remoteRef: string, options: LocalGitExecOptions = {} -): { ahead: number; behind: number } | null { +): Promise<{ ahead: number; behind: number } | null> { try { - const stdout = gitExecFileSync( + const { stdout } = await gitExecFileAsync( ['rev-list', '--left-right', '--count', `${localRef}...${remoteRef}`], - gitExecOptions(repoPath, options) + { + ...gitExecOptions(repoPath, options), + timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS + } ) const counts = parseGitRevListAheadBehindCounts(stdout) if (counts.status !== 'ok') { @@ -561,17 +565,20 @@ export function getRemoteDrift( * Up to `limit` commit subjects on remoteRef but not localRef, recency order; [] on git failure. * Powers the preamble drift section (§3.2) so a worker sees whether stale-base drift touches its area. */ -export function getRecentDriftSubjects( +export async function getRecentDriftSubjects( repoPath: string, localRef: string, remoteRef: string, limit: number, options: LocalGitExecOptions = {} -): string[] { +): Promise { try { - const stdout = gitExecFileSync( + const { stdout } = await gitExecFileAsync( ['log', '--format=%s', '-n', String(limit), `${localRef}..${remoteRef}`], - gitExecOptions(repoPath, options) + { + ...gitExecOptions(repoPath, options), + timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS + } ) return stdout.split('\n').filter((s) => s.trim().length > 0) } catch { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 7967d422f46..14d66cf1719 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7152,6 +7152,7 @@ describe('OrcaRuntimeService', () => { } const runtime = new OrcaRuntimeService(runtimeStore as never) const wslGitOptions = { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } + let driftCounts = '1\t2\n' const asyncGitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { if (args[0] === 'symbolic-ref') { return { stdout: 'refs/remotes/origin/main\n', stderr: '' } @@ -7168,19 +7169,14 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } + if (args[0] === 'rev-list') { + return { stdout: driftCounts, stderr: '' } + } + if (args[0] === 'log') { + return { stdout: 'base commit 2\nbase commit 1\n', stderr: '' } + } throw new Error(`unexpected git call: ${args.join(' ')}`) }) - const syncGitSpy = vi - .spyOn(gitRunner, 'gitExecFileSync') - .mockImplementation((args: string[]) => { - if (args[0] === 'rev-list') { - return '1\t2\n' - } - if (args[0] === 'log') { - return 'base commit 2\nbase commit 1\n' - } - throw new Error(`unexpected sync git call: ${args.join(' ')}`) - }) try { const result = await runtime.probeWorktreeDrift(`id:${TEST_WORKTREE_ID}`) @@ -7203,17 +7199,30 @@ describe('OrcaRuntimeService', () => { ...wslGitOptions, timeout: 60_000 }) - expect(syncGitSpy).toHaveBeenCalledWith( + expect(asyncGitSpy).toHaveBeenCalledWith( ['rev-list', '--left-right', '--count', 'HEAD...origin/main'], - { cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu' } + { cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu', timeout: 15_000 } ) - expect(syncGitSpy).toHaveBeenCalledWith( + expect(asyncGitSpy).toHaveBeenCalledWith( ['log', '--format=%s', '-n', '5', 'HEAD..origin/main'], - { cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu' } + { cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu', timeout: 15_000 } ) + + driftCounts = '3\t0\n' + asyncGitSpy.mockClear() + + await expect(runtime.probeWorktreeDrift(`id:${TEST_WORKTREE_ID}`)).resolves.toEqual({ + base: 'origin/main', + behind: 0, + recentSubjects: [] + }) + expect(asyncGitSpy).toHaveBeenCalledWith( + ['rev-list', '--left-right', '--count', 'HEAD...origin/main'], + { cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu', timeout: 15_000 } + ) + expect(asyncGitSpy.mock.calls.some(([args]) => args[0] === 'log')).toBe(false) } finally { asyncGitSpy.mockRestore() - syncGitSpy.mockRestore() } }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 33de03a4a09..a7730f7166e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -23582,17 +23582,21 @@ export class OrcaRuntimeService { // Why: fetch failures are non-fatal; we proceed with whatever the // last-known remote ref points at. `fetchRemoteWithCache` never throws. await this.fetchRemoteWithCache(repo.path, remote, localWorktreeGitOptions) - const drift = getRemoteDrift(wt.path, 'HEAD', base, localGitExecOptions) + const drift = await getRemoteDrift(wt.path, 'HEAD', base, localGitExecOptions) if (!drift) { return null } - const recentSubjects = getRecentDriftSubjects( - wt.path, - 'HEAD', - base, - DRIFT_PROBE_SUBJECT_LIMIT, - localGitExecOptions - ) + // Why: behind=0 proves HEAD..base is empty, so git log cannot add subjects. + const recentSubjects = + drift.behind > 0 + ? await getRecentDriftSubjects( + wt.path, + 'HEAD', + base, + DRIFT_PROBE_SUBJECT_LIMIT, + localGitExecOptions + ) + : [] return { base, behind: drift.behind, recentSubjects } } From f5d934a3f532b80113d98c07f57c65910fc7647c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:37:33 -0700 Subject: [PATCH 033/271] 3c5f0060 (#13496) * refactor(runtime): extract pure path-candidate, review-branch, and folder-workspace helpers from orca-runtime.ts Mechanical move of three closed, pure module-scope clusters out of orca-runtime.ts (37,608 -> 37,207 lines) into domain-named siblings: - terminal-output-path-candidates.ts: PTY output path harvesting and the recent-candidate history bound (3 entry points + 15 private callees). - selected-review-branch.ts: forge-agnostic selected-review predicates and lookup hints (GitHub/GitLab/Bitbucket/Azure DevOps/Gitea). - runtime-folder-workspace.ts: folder-workspace id math and the repo+meta -> Worktree projection. Bodies are token-identical to their previous form; the only production changes are the moves, the new import statements, and `export` keywords. The no-control-regex suppression travels with the path-candidate scanning that needs it. No max-lines suppression was added and the ratchet is unchanged. Adds characterization tests for the two clusters that had no direct coverage; the path-candidate cluster keeps its existing tests, repointed at the new module. * fix flaky timer on CI --- ...rca-runtime-path-candidate-history.test.ts | 2 +- src/main/runtime/orca-runtime.test.ts | 8 +- src/main/runtime/orca-runtime.ts | 437 +----------------- .../runtime/runtime-folder-workspace.test.ts | 190 ++++++++ src/main/runtime/runtime-folder-workspace.ts | 71 +++ .../runtime/selected-review-branch.test.ts | 182 ++++++++ src/main/runtime/selected-review-branch.ts | 93 ++++ .../terminal-output-path-candidates.ts | 269 +++++++++++ .../terminal-fit-restore.test.ts | 58 ++- 9 files changed, 864 insertions(+), 446 deletions(-) create mode 100644 src/main/runtime/runtime-folder-workspace.test.ts create mode 100644 src/main/runtime/runtime-folder-workspace.ts create mode 100644 src/main/runtime/selected-review-branch.test.ts create mode 100644 src/main/runtime/selected-review-branch.ts create mode 100644 src/main/runtime/terminal-output-path-candidates.ts diff --git a/src/main/runtime/orca-runtime-path-candidate-history.test.ts b/src/main/runtime/orca-runtime-path-candidate-history.test.ts index 80b09ed81e3..516abfa1255 100644 --- a/src/main/runtime/orca-runtime-path-candidate-history.test.ts +++ b/src/main/runtime/orca-runtime-path-candidate-history.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { appendRecentPtyPathCandidates } from './orca-runtime' +import { appendRecentPtyPathCandidates } from './terminal-output-path-candidates' function fullCandidateHistory(): string[] { return Array.from( diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 14d66cf1719..90a40f9da06 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -61,14 +61,16 @@ import type { MessagePriority, MessageRow, MessageType } from './orchestration/t import { AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS, appendNormalizedToTailBuffer, - appendRecentPtyPathCandidates, buildPreview, OrcaRuntimeService, - recentTerminalPathCandidatesIncludePath, - recentTerminalOutputIncludesPath, resolveWorktreeScanCacheTtlMs, type RuntimeTerminalAgentStatusEvent } from './orca-runtime' +import { + appendRecentPtyPathCandidates, + recentTerminalPathCandidatesIncludePath, + recentTerminalOutputIncludesPath +} from './terminal-output-path-candidates' import { RecentPtyOutputBuffer } from './recent-pty-output-buffer' import { HeadlessEmulator } from '../daemon/headless-emulator' import { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a7730f7166e..7c5eeed2c81 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -672,7 +672,6 @@ import type { HostedReviewInfo } from '../../shared/hosted-review' import { getHostedReviewForBranch as getHostedReviewForBranchFromRepo } from '../source-control/hosted-review' -import type { ForgeProviderId } from '../source-control/forge-provider' import { createHostedReview as createHostedReviewFromRepo, getHostedReviewCreationEligibility as getHostedReviewCreationEligibilityFromRepo @@ -983,6 +982,24 @@ import { type MobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer' import { TerminalFocusNavigationCoalescer } from './terminal-focus-navigation-coalescer' +import { + appendRecentPtyPathCandidates, + recentTerminalOutputIncludesPath, + recentTerminalPathCandidatesIncludePath +} from './terminal-output-path-candidates' +import { + getSelectedReviewBranch, + getSelectedReviewLookupHints, + isAllowedPushTargetRemoteConflict, + isMatchingSelectedGitHubPr, + type SelectedReviewBranchInput +} from './selected-review-branch' +import { + getRuntimeFolderWorkspaceInstanceId, + getRuntimeFolderWorkspaceRootId, + isRuntimeFolderWorkspaceIdForRepo, + mergeRuntimeFolderWorkspace +} from './runtime-folder-workspace' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { assertFolderWorkspacePathUsable, @@ -1772,9 +1789,6 @@ const BRACKETED_PASTE_QUIET_MS = 1500 const DRAFT_PASTE_READY_TIMEOUT_MS = 8000 const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000 const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000 -const RECENT_PTY_PATH_CANDIDATE_LIMIT = 1024 -const RECENT_PTY_PATH_CANDIDATE_MAX_BYTES = 4 * 1024 -const RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES = 64 * 1024 const SSH_PANE_RECOVERY_GRACE_MS = 30_000 // Why: long enough that a keystroke burst to a proven-dead leaf probes once, // short enough that a recreated session id regains writability quickly even if @@ -2111,10 +2125,6 @@ function getRuntimeWorktreeRemovalOptionsKey( return `${force ? 'force' : 'normal'}:${runHooks ? 'run-hooks' : 'skip-hooks'}:${ptyKey}` } -function getRuntimeFolderWorkspaceRootId(repo: Repo): string { - return `${repo.id}::${repo.path}` -} - // Null executionHostId means host-unaware: path-only callers match any repo, and the first runtime // host can adopt a legacy (unstamped) repo. But an unstamped repo with a connectionId is an SSH repo // (resolves to ssh:), so it must not be adopted/matched by a runtime host at the same path. @@ -2144,71 +2154,11 @@ function assertProjectHostSetupHostIsSupported(hostId: ExecutionHostId | null | ) } -function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { - return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` -} - function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { const prefix = `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() } -function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { - const rootId = getRuntimeFolderWorkspaceRootId(repo) - return ( - worktreeId === rootId || - worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) - ) -} - -function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree { - return { - id: worktreeId, - ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), - repoId: repo.id, - ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), - ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), - ...(meta.projectHostSetupId !== undefined - ? { projectHostSetupId: meta.projectHostSetupId } - : {}), - path: repo.path, - head: '', - branch: '', - isBare: false, - isMainWorktree: worktreeId === getRuntimeFolderWorkspaceRootId(repo), - displayName: meta.displayName || repo.displayName, - comment: meta.comment || '', - linkedIssue: meta.linkedIssue ?? null, - linkedPR: meta.linkedPR ?? null, - linkedLinearIssue: meta.linkedLinearIssue ?? null, - linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null, - linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null, - linkedGitLabMR: meta.linkedGitLabMR ?? null, - linkedGitLabIssue: meta.linkedGitLabIssue ?? null, - linkedBitbucketPR: meta.linkedBitbucketPR ?? null, - linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: meta.linkedGiteaPR ?? null, - linkedWorkItem: meta.linkedWorkItem ?? null, - linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, - isArchived: meta.isArchived ?? false, - isUnread: meta.isUnread ?? false, - isPinned: meta.isPinned ?? false, - sortOrder: meta.sortOrder ?? 0, - ...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), - lastActivityAt: meta.lastActivityAt ?? 0, - ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), - ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), - ...(meta.automationProvenance !== undefined - ? { automationProvenance: meta.automationProvenance } - : {}), - ...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}), - ...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}), - workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, - diffComments: meta.diffComments, - mobileDiffReview: meta.mobileDiffReview - } -} - function listRuntimeFolderWorkspaces( store: Pick, repo: Repo @@ -2369,94 +2319,6 @@ function getLocalGitHubPrForBranch( : getPRForBranch(repoPath, branchName) } -type SelectedReviewBranchInput = { - branchNameOverride?: string - linkedPR?: number | null - linkedGitLabMR?: number | null - linkedBitbucketPR?: number | null - linkedAzureDevOpsPR?: number | null - linkedGiteaPR?: number | null - pushTarget?: GitPushTarget -} - -type SelectedReviewBranch = { - provider: ForgeProviderId - number: number -} - -function getSelectedReviewBranch(args: SelectedReviewBranchInput): SelectedReviewBranch | null { - if (typeof args.linkedPR === 'number') { - return { provider: 'github', number: args.linkedPR } - } - if (typeof args.linkedGitLabMR === 'number') { - return { provider: 'gitlab', number: args.linkedGitLabMR } - } - if (typeof args.linkedBitbucketPR === 'number') { - return { provider: 'bitbucket', number: args.linkedBitbucketPR } - } - if (typeof args.linkedAzureDevOpsPR === 'number') { - return { provider: 'azure-devops', number: args.linkedAzureDevOpsPR } - } - if (typeof args.linkedGiteaPR === 'number') { - return { provider: 'gitea', number: args.linkedGiteaPR } - } - return null -} - -function isSelectedGitHubPrBranchOverride( - args: SelectedReviewBranchInput, - branchName: string -): boolean { - return typeof args.linkedPR === 'number' && args.branchNameOverride === branchName -} - -function isSelectedReviewBranchOverride( - args: SelectedReviewBranchInput, - branchName: string -): boolean { - return getSelectedReviewBranch(args) !== null && args.branchNameOverride === branchName -} - -function isMatchingSelectedGitHubPr( - existingPR: Awaited>, - args: SelectedReviewBranchInput, - branchName: string -): boolean { - return Boolean( - existingPR && - isSelectedGitHubPrBranchOverride(args, branchName) && - existingPR.number === args.linkedPR - ) -} - -function isAllowedPushTargetRemoteConflict( - conflictKind: 'local' | 'remote' | null, - branchName: string, - args: SelectedReviewBranchInput -): boolean { - return ( - conflictKind === 'remote' && - isSelectedReviewBranchOverride(args, branchName) && - args.pushTarget?.branchName === branchName - ) -} - -function getSelectedReviewLookupHints(args: SelectedReviewBranchInput): { - linkedGitHubPR?: number | null - linkedGitLabMR?: number | null - linkedBitbucketPR?: number | null - linkedAzureDevOpsPR?: number | null - linkedGiteaPR?: number | null -} { - return { - linkedGitHubPR: args.linkedPR ?? null, - linkedGitLabMR: args.linkedGitLabMR ?? null, - linkedBitbucketPR: args.linkedBitbucketPR ?? null, - linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: args.linkedGiteaPR ?? null - } -} - async function getSelectedHostedReviewForBranch( repo: Pick, branchName: string, @@ -35298,269 +35160,6 @@ function withTimeoutResult( ) } -export function appendRecentPtyPathCandidates( - previous: string[] | undefined, - data: string -): string[] { - const extractedCandidates = extractTerminalOutputPathCandidates(data) - if (extractedCandidates.length === 0) { - // Why: pathless output is the hot path; reuse immutable history so each chunk doesn't clone and byte-scan up to 1,024 old candidates. - return previous ?? [] - } - const next = previous ? previous.slice() : [] - for (const candidate of extractedCandidates) { - if (Buffer.byteLength(candidate, 'utf8') > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { - continue - } - next.push(candidate) - } - return pruneRecentPtyPathCandidates(next) -} - -export function recentTerminalPathCandidatesIncludePath( - recentCandidates: readonly string[], - pathText: string, - absolutePath: string -): boolean { - const candidates = new Set( - [ - pathText, - absolutePath, - ...wslTerminalOutputAliases(pathText), - ...wslTerminalOutputAliases(absolutePath) - ] - .map((candidate) => candidate.trim()) - .filter((candidate) => candidate.length > 0) - ) - for (const recent of recentCandidates) { - if (candidates.has(recent)) { - return true - } - } - return false -} - -function pruneRecentPtyPathCandidates(candidates: string[]): string[] { - const countBounded = - candidates.length > RECENT_PTY_PATH_CANDIDATE_LIMIT - ? candidates.slice(-RECENT_PTY_PATH_CANDIDATE_LIMIT) - : candidates - let totalBytes = 0 - let startIndex = countBounded.length - for (let index = countBounded.length - 1; index >= 0; index -= 1) { - const nextTotal = totalBytes + Buffer.byteLength(countBounded[index]!, 'utf8') - if (nextTotal > RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES) { - break - } - totalBytes = nextTotal - startIndex = index - } - return startIndex === 0 ? countBounded : countBounded.slice(startIndex) -} - -export function recentTerminalOutputIncludesPath( - recentOutput: string, - pathText: string, - absolutePath: string -): boolean { - const candidates = new Set( - [pathText, absolutePath] - .map((candidate) => candidate.trim()) - .filter((candidate) => candidate.length > 0) - ) - if (candidates.size === 0) { - return false - } - for (const candidate of candidates) { - if (outputContainsPathCandidate(recentOutput, candidate)) { - return true - } - } - const decodedOutput = decodeTerminalOutputPercentEscapes(recentOutput) - if (decodedOutput !== recentOutput) { - for (const candidate of candidates) { - if (outputContainsPathCandidate(decodedOutput, candidate)) { - return true - } - } - } - return false -} - -function outputContainsPathCandidate(output: string, candidate: string): boolean { - let start = output.indexOf(candidate) - while (start !== -1) { - const end = start + candidate.length - if (isPathCandidateStartBoundary(output, start) && isPathCandidateEndBoundary(output, end)) { - return true - } - start = output.indexOf(candidate, start + 1) - } - return false -} - -function isPathCandidateStartBoundary(output: string, start: number): boolean { - if (start === 0) { - return true - } - if (output.slice(0, start).endsWith('file://')) { - return true - } - if ( - /^[A-Za-z]:[\\/]/.test(output.slice(start)) && - /file:\/\/(?:localhost|127\.0\.0\.1|\[?::1\]?)?\/$/i.test(output.slice(0, start)) - ) { - return true - } - if (/file:\/\/(?:localhost|127\.0\.0\.1|\[?::1\]?)$/i.test(output.slice(0, start))) { - return true - } - return !isPathCandidateContinuationChar(output[start - 1]!) -} - -function isPathCandidateEndBoundary(output: string, end: number): boolean { - const next = output[end] - if (!next) { - return true - } - if (next === ':' && /^\d+(?::\d+)?(?:\D|$)/.test(output.slice(end + 1))) { - return true - } - return !isPathCandidateContinuationChar(next) -} - -function isPathCandidateContinuationChar(char: string): boolean { - return /[A-Za-z0-9._~/%+@\\()[\]-]/.test(char) -} - -function decodeTerminalOutputPercentEscapes(value: string): string { - return value.replace(/(?:%[0-9a-f]{2})+/gi, (match) => { - try { - return decodeURIComponent(match) - } catch { - return match - } - }) -} - -// Why: the extension regex backtracks quadratically on the PTY hot path; candidates can't cross a newline, so scan per line and skip over-long lines (dropped anyway). -function extractTerminalOutputPathCandidates(data: string): string[] { - const candidates: string[] = [] - const add = (value: string): void => { - const candidate = trimTerminalOutputPathCandidate(value) - if (candidate.length > 0) { - candidates.push(candidate) - const drivePath = normalizeTerminalOutputFileUriDrivePath(candidate) - if (drivePath) { - candidates.push(drivePath) - } - } - } - for (const line of data.split(/[\r\n]+/)) { - if (line.length === 0 || line.length > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { - continue - } - collectTerminalOutputLinePathCandidates(line, add) - } - return candidates -} - -function collectTerminalOutputLinePathCandidates(line: string, add: (value: string) => void): void { - for (const match of line.matchAll(/file:\/\/([^/\s]*)(\/[^\s\x1b"'<>)]*)/gi)) { - const authority = match[1] ?? '' - const uriPath = match[2] - if (uriPath) { - const decoded = decodeTerminalOutputPercentEscapes(uriPath) - add(isTerminalOutputLoopbackAuthority(authority) ? decoded : `//${authority}${decoded}`) - } - } - for (const match of line.matchAll( - /(?:\/(?:tmp|private\/tmp)\/|[A-Za-z]:[\\/])[^\r\n\x1b"'<>]+/g - )) { - if (isInsideNonLocalFileUri(line, match.index)) { - continue - } - add(match[0]) - } - for (const match of line.matchAll( - /\/[^\r\n\x1b"'<>]*\.[A-Za-z0-9_+-]+(?:[#:\s][^\r\n\x1b"'<>]*)?/g - )) { - if (isInsideNonLocalFileUri(line, match.index)) { - continue - } - add(match[0]) - } -} - -function normalizeTerminalOutputFileUriDrivePath(candidate: string): string | null { - return /^\/[A-Za-z]:[\\/]/.test(candidate) ? candidate.slice(1) : null -} - -function trimTerminalOutputPathCandidate(value: string): string { - let candidate = value.trim().replace(/[),;.]+$/g, '') - if (Buffer.byteLength(candidate, 'utf8') > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { - return '' - } - let selected: string | null = null - for (const match of candidate.matchAll( - /.+?\.[A-Za-z0-9_+-]+(?:#L\d+(?:C\d+)?|(?::\d+)?(?::\d+)?)?(?=\s+|$)/gi - )) { - const end = match.index + match[0].length - const text = candidate.slice(0, end) - if (countTerminalOutputPathStarts(text) > 1) { - continue - } - // Same as the tap parsers: a line-end token extends the candidate only when the added segment is path-like, so trailing prose isn't swallowed. - if ( - end < candidate.length || - selected === null || - /[\\/]/.test(candidate.slice(selected.length, end)) - ) { - selected = text - } - } - return trimTerminalOutputPathLocator(selected ?? candidate) -} - -function isTerminalOutputLoopbackAuthority(authority: string): boolean { - const normalized = authority.toLowerCase() - return ( - normalized === '' || - normalized === 'localhost' || - normalized === '127.0.0.1' || - normalized === '::1' || - normalized === '[::1]' - ) -} - -function isInsideNonLocalFileUri(output: string, pathStart: number): boolean { - const prefix = output.slice(0, pathStart) - const match = /file:\/\/([^/\s]*)$/i.exec(prefix) - return !!match && !isTerminalOutputLoopbackAuthority(match[1] ?? '') -} - -function countTerminalOutputPathStarts(value: string): number { - let count = 0 - for (const match of value.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) { - void match - count += 1 - } - return count -} - -function trimTerminalOutputPathLocator(value: string): string { - return value.replace(/#L\d+(?:C\d+)?$/i, '').replace(/:\d+(?::\d+)?$/, '') -} - -function wslTerminalOutputAliases(value: string): string[] { - const match = /^\\\\wsl(?:\.localhost|\$)\\[^\\]+(\\.*)$/i.exec(value) - if (!match) { - return [] - } - const linuxPath = match[1]!.replace(/\\/g, '/') - return linuxPath.startsWith('/') ? [linuxPath] : [`/${linuxPath}`] -} - export function buildPreview(lines: string[], partialLine: string): string { const previewLines: string[] = [] const collectVisibleLine = (line: string): void => { diff --git a/src/main/runtime/runtime-folder-workspace.test.ts b/src/main/runtime/runtime-folder-workspace.test.ts new file mode 100644 index 00000000000..f00da160330 --- /dev/null +++ b/src/main/runtime/runtime-folder-workspace.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import type { Repo, WorktreeMeta } from '../../shared/types' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' +import { + getRuntimeFolderWorkspaceInstanceId, + getRuntimeFolderWorkspaceRootId, + isRuntimeFolderWorkspaceIdForRepo, + mergeRuntimeFolderWorkspace +} from './runtime-folder-workspace' + +// characterization: current behavior + +function repo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/Users/dev/projects/site', + displayName: 'site', + badgeColor: '#000000', + addedAt: 0, + ...overrides + } +} + +function meta(overrides: Partial = {}): WorktreeMeta { + return { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +describe('getRuntimeFolderWorkspaceRootId', () => { + it.each([ + { label: 'posix path', input: repo(), expected: 'repo-1::/Users/dev/projects/site' }, + { + label: 'windows path', + input: repo({ path: 'C:\\Users\\dev\\site' }), + expected: 'repo-1::C:\\Users\\dev\\site' + }, + { + label: 'ssh repo (connectionId is not part of the id)', + input: repo({ connectionId: 'ssh-1', path: '/home/dev/site' }), + expected: 'repo-1::/home/dev/site' + }, + { label: 'empty path', input: repo({ path: '' }), expected: 'repo-1::' } + ])('joins repo id and path for $label', ({ input, expected }) => { + expect(getRuntimeFolderWorkspaceRootId(input)).toBe(expected) + }) +}) + +describe('getRuntimeFolderWorkspaceInstanceId', () => { + it('appends the instance id after the separator', () => { + expect(getRuntimeFolderWorkspaceInstanceId(repo(), 'abc')).toBe( + `repo-1::/Users/dev/projects/site${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}abc` + ) + }) + + it('appends an empty instance id without complaint', () => { + expect(getRuntimeFolderWorkspaceInstanceId(repo(), '')).toBe( + `repo-1::/Users/dev/projects/site${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + ) + }) +}) + +describe('isRuntimeFolderWorkspaceIdForRepo', () => { + const root = 'repo-1::/Users/dev/projects/site' + + it.each([ + { label: 'the root id itself', worktreeId: root, expected: true }, + { + label: 'an instance id under the root', + worktreeId: `${root}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}abc`, + expected: true + }, + { + label: 'a sibling path that merely shares the root as a prefix', + worktreeId: `${root}-other`, + expected: false + }, + { label: 'another repo', worktreeId: 'repo-2::/Users/dev/projects/site', expected: false }, + { label: 'an unrelated id', worktreeId: '', expected: false } + ])('$label', ({ worktreeId, expected }) => { + expect(isRuntimeFolderWorkspaceIdForRepo(repo(), worktreeId)).toBe(expected) + }) +}) + +describe('mergeRuntimeFolderWorkspace', () => { + it('projects repo + meta onto a branchless, headless worktree', () => { + const merged = mergeRuntimeFolderWorkspace(repo(), 'repo-1::/Users/dev/projects/site', meta()) + + expect(merged).toEqual({ + id: 'repo-1::/Users/dev/projects/site', + repoId: 'repo-1', + path: '/Users/dev/projects/site', + head: '', + branch: '', + isBare: false, + isMainWorktree: true, + displayName: 'site', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + linkedWorkItem: null, + linkedTaskSourceContext: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'in-progress', + diffComments: undefined, + mobileDiffReview: undefined + }) + }) + + it('marks a non-root id as a secondary workspace', () => { + const worktreeId = `repo-1::/Users/dev/projects/site${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}abc` + const merged = mergeRuntimeFolderWorkspace(repo(), worktreeId, meta({ instanceId: 'abc' })) + + expect(merged.isMainWorktree).toBe(false) + expect(merged.instanceId).toBe('abc') + expect(merged.path).toBe('/Users/dev/projects/site') + }) + + it('falls back to the repo display name when meta has a blank one', () => { + expect(mergeRuntimeFolderWorkspace(repo(), 'id', meta()).displayName).toBe('site') + expect( + mergeRuntimeFolderWorkspace(repo(), 'id', meta({ displayName: 'custom' })).displayName + ).toBe('custom') + }) + + it('omits optional keys rather than writing undefined for them', () => { + const merged = mergeRuntimeFolderWorkspace(repo(), 'id', meta()) + + expect('instanceId' in merged).toBe(false) + expect('projectId' in merged).toBe(false) + expect('hostId' in merged).toBe(false) + expect('projectHostSetupId' in merged).toBe(false) + expect('manualOrder' in merged).toBe(false) + expect('createdAt' in merged).toBe(false) + expect('createdWithAgent' in merged).toBe(false) + expect('automationProvenance' in merged).toBe(false) + expect('cliProvenance' in merged).toBe(false) + expect('priorWorktreeIds' in merged).toBe(false) + }) + + it('carries project-first ownership fields through when present', () => { + const merged = mergeRuntimeFolderWorkspace( + repo(), + 'id', + meta({ + projectId: 'project-1', + hostId: 'local', + projectHostSetupId: 'setup-1', + manualOrder: 3, + createdAt: 42, + priorWorktreeIds: ['old-id'] + }) + ) + + expect(merged.projectId).toBe('project-1') + expect(merged.hostId).toBe('local') + expect(merged.projectHostSetupId).toBe('setup-1') + expect(merged.manualOrder).toBe(3) + expect(merged.createdAt).toBe(42) + expect(merged.priorWorktreeIds).toEqual(['old-id']) + }) + + it('keeps an explicit workspace status instead of the default', () => { + expect( + mergeRuntimeFolderWorkspace(repo(), 'id', meta({ workspaceStatus: 'done' })).workspaceStatus + ).toBe('done') + }) +}) diff --git a/src/main/runtime/runtime-folder-workspace.ts b/src/main/runtime/runtime-folder-workspace.ts new file mode 100644 index 00000000000..ba17a7e03e1 --- /dev/null +++ b/src/main/runtime/runtime-folder-workspace.ts @@ -0,0 +1,71 @@ +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' +import type { Repo, Worktree, WorktreeMeta } from '../../shared/types' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' + +export function getRuntimeFolderWorkspaceRootId(repo: Repo): string { + return `${repo.id}::${repo.path}` +} + +export function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { + return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` +} + +export function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { + const rootId = getRuntimeFolderWorkspaceRootId(repo) + return ( + worktreeId === rootId || + worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) + ) +} + +export function mergeRuntimeFolderWorkspace( + repo: Repo, + worktreeId: string, + meta: WorktreeMeta +): Worktree { + return { + id: worktreeId, + ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), + repoId: repo.id, + ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), + ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), + ...(meta.projectHostSetupId !== undefined + ? { projectHostSetupId: meta.projectHostSetupId } + : {}), + path: repo.path, + head: '', + branch: '', + isBare: false, + isMainWorktree: worktreeId === getRuntimeFolderWorkspaceRootId(repo), + displayName: meta.displayName || repo.displayName, + comment: meta.comment || '', + linkedIssue: meta.linkedIssue ?? null, + linkedPR: meta.linkedPR ?? null, + linkedLinearIssue: meta.linkedLinearIssue ?? null, + linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null, + linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null, + linkedGitLabMR: meta.linkedGitLabMR ?? null, + linkedGitLabIssue: meta.linkedGitLabIssue ?? null, + linkedBitbucketPR: meta.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null, + linkedGiteaPR: meta.linkedGiteaPR ?? null, + linkedWorkItem: meta.linkedWorkItem ?? null, + linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, + isArchived: meta.isArchived ?? false, + isUnread: meta.isUnread ?? false, + isPinned: meta.isPinned ?? false, + sortOrder: meta.sortOrder ?? 0, + ...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), + lastActivityAt: meta.lastActivityAt ?? 0, + ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), + ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), + ...(meta.automationProvenance !== undefined + ? { automationProvenance: meta.automationProvenance } + : {}), + ...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}), + ...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}), + workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, + diffComments: meta.diffComments, + mobileDiffReview: meta.mobileDiffReview + } +} diff --git a/src/main/runtime/selected-review-branch.test.ts b/src/main/runtime/selected-review-branch.test.ts new file mode 100644 index 00000000000..533c971480b --- /dev/null +++ b/src/main/runtime/selected-review-branch.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import { + getSelectedReviewBranch, + getSelectedReviewLookupHints, + isAllowedPushTargetRemoteConflict, + isMatchingSelectedGitHubPr, + type SelectedReviewBranchInput +} from './selected-review-branch' + +// characterization: current behavior + +const PROVIDER_CASES: { + label: string + input: SelectedReviewBranchInput + expected: { provider: string; number: number } | null +}[] = [ + { label: 'no linked review', input: {}, expected: null }, + { label: 'github', input: { linkedPR: 7 }, expected: { provider: 'github', number: 7 } }, + { + label: 'gitlab', + input: { linkedGitLabMR: 12 }, + expected: { provider: 'gitlab', number: 12 } + }, + { + label: 'bitbucket', + input: { linkedBitbucketPR: 3 }, + expected: { provider: 'bitbucket', number: 3 } + }, + { + label: 'azure devops', + input: { linkedAzureDevOpsPR: 44 }, + expected: { provider: 'azure-devops', number: 44 } + }, + { label: 'gitea', input: { linkedGiteaPR: 9 }, expected: { provider: 'gitea', number: 9 } }, + { + label: 'zero is a real number', + input: { linkedPR: 0 }, + expected: { provider: 'github', number: 0 } + }, + { + label: 'null linked ids are skipped', + input: { linkedPR: null, linkedGitLabMR: null }, + expected: null + }, + { + label: 'github wins over gitlab when both are set', + input: { linkedPR: 1, linkedGitLabMR: 2 }, + expected: { provider: 'github', number: 1 } + }, + { + label: 'gitlab wins over bitbucket when both are set', + input: { linkedGitLabMR: 2, linkedBitbucketPR: 3 }, + expected: { provider: 'gitlab', number: 2 } + } +] + +describe('getSelectedReviewBranch', () => { + it.each(PROVIDER_CASES)('resolves $label', ({ input, expected }) => { + expect(getSelectedReviewBranch(input)).toEqual(expected) + }) +}) + +type ExistingPr = Parameters[0] + +const pr = (number: number): ExistingPr => ({ number }) as ExistingPr + +describe('isMatchingSelectedGitHubPr', () => { + it('matches when the PR number and the branch override both line up', () => { + expect( + isMatchingSelectedGitHubPr(pr(7), { linkedPR: 7, branchNameOverride: 'feat' }, 'feat') + ).toBe(true) + }) + + it('rejects a different PR number', () => { + expect( + isMatchingSelectedGitHubPr(pr(8), { linkedPR: 7, branchNameOverride: 'feat' }, 'feat') + ).toBe(false) + }) + + it('rejects when the branch override does not equal the branch', () => { + expect( + isMatchingSelectedGitHubPr(pr(7), { linkedPR: 7, branchNameOverride: 'other' }, 'feat') + ).toBe(false) + }) + + it('rejects when there is no branch override at all', () => { + expect(isMatchingSelectedGitHubPr(pr(7), { linkedPR: 7 }, 'feat')).toBe(false) + }) + + it('rejects a non-GitHub selection even when the branch override matches', () => { + expect( + isMatchingSelectedGitHubPr(pr(7), { linkedGitLabMR: 7, branchNameOverride: 'feat' }, 'feat') + ).toBe(false) + }) + + it('returns false for a null existing PR', () => { + expect( + isMatchingSelectedGitHubPr(null, { linkedPR: 7, branchNameOverride: 'feat' }, 'feat') + ).toBe(false) + }) +}) + +describe('isAllowedPushTargetRemoteConflict', () => { + const args: SelectedReviewBranchInput = { + linkedGitLabMR: 12, + branchNameOverride: 'feat', + pushTarget: { remoteName: 'origin', branchName: 'feat' } + } + + it('allows a remote conflict on the selected review branch', () => { + expect(isAllowedPushTargetRemoteConflict('remote', 'feat', args)).toBe(true) + }) + + it('does not allow a local conflict', () => { + expect(isAllowedPushTargetRemoteConflict('local', 'feat', args)).toBe(false) + }) + + it('does not allow a null conflict kind', () => { + expect(isAllowedPushTargetRemoteConflict(null, 'feat', args)).toBe(false) + }) + + it('requires a linked review', () => { + expect( + isAllowedPushTargetRemoteConflict('remote', 'feat', { + branchNameOverride: 'feat', + pushTarget: args.pushTarget + }) + ).toBe(false) + }) + + it('requires the push target to name the same branch', () => { + expect( + isAllowedPushTargetRemoteConflict('remote', 'feat', { + ...args, + pushTarget: { remoteName: 'origin', branchName: 'elsewhere' } + }) + ).toBe(false) + }) + + it('requires a push target', () => { + expect( + isAllowedPushTargetRemoteConflict('remote', 'feat', { + linkedGitLabMR: 12, + branchNameOverride: 'feat' + }) + ).toBe(false) + }) +}) + +describe('getSelectedReviewLookupHints', () => { + it('normalizes every absent hint to null', () => { + expect(getSelectedReviewLookupHints({})).toEqual({ + linkedGitHubPR: null, + linkedGitLabMR: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null + }) + }) + + it('renames linkedPR to linkedGitHubPR and passes the rest through', () => { + expect( + getSelectedReviewLookupHints({ + linkedPR: 7, + linkedGitLabMR: 12, + linkedBitbucketPR: 3, + linkedAzureDevOpsPR: 44, + linkedGiteaPR: 9 + }) + ).toEqual({ + linkedGitHubPR: 7, + linkedGitLabMR: 12, + linkedBitbucketPR: 3, + linkedAzureDevOpsPR: 44, + linkedGiteaPR: 9 + }) + }) + + it('keeps 0 rather than coercing it to null', () => { + expect(getSelectedReviewLookupHints({ linkedPR: 0 }).linkedGitHubPR).toBe(0) + }) +}) diff --git a/src/main/runtime/selected-review-branch.ts b/src/main/runtime/selected-review-branch.ts new file mode 100644 index 00000000000..f874ab8a1ab --- /dev/null +++ b/src/main/runtime/selected-review-branch.ts @@ -0,0 +1,93 @@ +import type { GitPushTarget } from '../../shared/types' +import type { ForgeProviderId } from '../source-control/forge-provider' +import type { getPRForBranch } from '../github/client' + +export type SelectedReviewBranchInput = { + branchNameOverride?: string + linkedPR?: number | null + linkedGitLabMR?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null + pushTarget?: GitPushTarget +} + +type SelectedReviewBranch = { + provider: ForgeProviderId + number: number +} + +export function getSelectedReviewBranch( + args: SelectedReviewBranchInput +): SelectedReviewBranch | null { + if (typeof args.linkedPR === 'number') { + return { provider: 'github', number: args.linkedPR } + } + if (typeof args.linkedGitLabMR === 'number') { + return { provider: 'gitlab', number: args.linkedGitLabMR } + } + if (typeof args.linkedBitbucketPR === 'number') { + return { provider: 'bitbucket', number: args.linkedBitbucketPR } + } + if (typeof args.linkedAzureDevOpsPR === 'number') { + return { provider: 'azure-devops', number: args.linkedAzureDevOpsPR } + } + if (typeof args.linkedGiteaPR === 'number') { + return { provider: 'gitea', number: args.linkedGiteaPR } + } + return null +} + +function isSelectedGitHubPrBranchOverride( + args: SelectedReviewBranchInput, + branchName: string +): boolean { + return typeof args.linkedPR === 'number' && args.branchNameOverride === branchName +} + +function isSelectedReviewBranchOverride( + args: SelectedReviewBranchInput, + branchName: string +): boolean { + return getSelectedReviewBranch(args) !== null && args.branchNameOverride === branchName +} + +export function isMatchingSelectedGitHubPr( + existingPR: Awaited>, + args: SelectedReviewBranchInput, + branchName: string +): boolean { + return Boolean( + existingPR && + isSelectedGitHubPrBranchOverride(args, branchName) && + existingPR.number === args.linkedPR + ) +} + +export function isAllowedPushTargetRemoteConflict( + conflictKind: 'local' | 'remote' | null, + branchName: string, + args: SelectedReviewBranchInput +): boolean { + return ( + conflictKind === 'remote' && + isSelectedReviewBranchOverride(args, branchName) && + args.pushTarget?.branchName === branchName + ) +} + +export function getSelectedReviewLookupHints(args: SelectedReviewBranchInput): { + linkedGitHubPR?: number | null + linkedGitLabMR?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null +} { + return { + linkedGitHubPR: args.linkedPR ?? null, + linkedGitLabMR: args.linkedGitLabMR ?? null, + linkedBitbucketPR: args.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, + linkedGiteaPR: args.linkedGiteaPR ?? null + } +} diff --git a/src/main/runtime/terminal-output-path-candidates.ts b/src/main/runtime/terminal-output-path-candidates.ts new file mode 100644 index 00000000000..de9eb82c0f8 --- /dev/null +++ b/src/main/runtime/terminal-output-path-candidates.ts @@ -0,0 +1,269 @@ +/* eslint-disable no-control-regex -- Why: PTY output interleaves ANSI escapes with paths, so candidate scanning must terminate matches on the control bytes themselves. */ +// Path candidates harvested from PTY output so terminal artifact links can be +// resolved without re-scanning the whole transcript. +const RECENT_PTY_PATH_CANDIDATE_LIMIT = 1024 +const RECENT_PTY_PATH_CANDIDATE_MAX_BYTES = 4 * 1024 +const RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES = 64 * 1024 + +export function appendRecentPtyPathCandidates( + previous: string[] | undefined, + data: string +): string[] { + const extractedCandidates = extractTerminalOutputPathCandidates(data) + if (extractedCandidates.length === 0) { + // Why: pathless output is the hot path; reuse immutable history so each chunk doesn't clone and byte-scan up to 1,024 old candidates. + return previous ?? [] + } + const next = previous ? previous.slice() : [] + for (const candidate of extractedCandidates) { + if (Buffer.byteLength(candidate, 'utf8') > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { + continue + } + next.push(candidate) + } + return pruneRecentPtyPathCandidates(next) +} + +export function recentTerminalPathCandidatesIncludePath( + recentCandidates: readonly string[], + pathText: string, + absolutePath: string +): boolean { + const candidates = new Set( + [ + pathText, + absolutePath, + ...wslTerminalOutputAliases(pathText), + ...wslTerminalOutputAliases(absolutePath) + ] + .map((candidate) => candidate.trim()) + .filter((candidate) => candidate.length > 0) + ) + for (const recent of recentCandidates) { + if (candidates.has(recent)) { + return true + } + } + return false +} + +function pruneRecentPtyPathCandidates(candidates: string[]): string[] { + const countBounded = + candidates.length > RECENT_PTY_PATH_CANDIDATE_LIMIT + ? candidates.slice(-RECENT_PTY_PATH_CANDIDATE_LIMIT) + : candidates + let totalBytes = 0 + let startIndex = countBounded.length + for (let index = countBounded.length - 1; index >= 0; index -= 1) { + const nextTotal = totalBytes + Buffer.byteLength(countBounded[index]!, 'utf8') + if (nextTotal > RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES) { + break + } + totalBytes = nextTotal + startIndex = index + } + return startIndex === 0 ? countBounded : countBounded.slice(startIndex) +} + +export function recentTerminalOutputIncludesPath( + recentOutput: string, + pathText: string, + absolutePath: string +): boolean { + const candidates = new Set( + [pathText, absolutePath] + .map((candidate) => candidate.trim()) + .filter((candidate) => candidate.length > 0) + ) + if (candidates.size === 0) { + return false + } + for (const candidate of candidates) { + if (outputContainsPathCandidate(recentOutput, candidate)) { + return true + } + } + const decodedOutput = decodeTerminalOutputPercentEscapes(recentOutput) + if (decodedOutput !== recentOutput) { + for (const candidate of candidates) { + if (outputContainsPathCandidate(decodedOutput, candidate)) { + return true + } + } + } + return false +} + +function outputContainsPathCandidate(output: string, candidate: string): boolean { + let start = output.indexOf(candidate) + while (start !== -1) { + const end = start + candidate.length + if (isPathCandidateStartBoundary(output, start) && isPathCandidateEndBoundary(output, end)) { + return true + } + start = output.indexOf(candidate, start + 1) + } + return false +} + +function isPathCandidateStartBoundary(output: string, start: number): boolean { + if (start === 0) { + return true + } + if (output.slice(0, start).endsWith('file://')) { + return true + } + if ( + /^[A-Za-z]:[\\/]/.test(output.slice(start)) && + /file:\/\/(?:localhost|127\.0\.0\.1|\[?::1\]?)?\/$/i.test(output.slice(0, start)) + ) { + return true + } + if (/file:\/\/(?:localhost|127\.0\.0\.1|\[?::1\]?)$/i.test(output.slice(0, start))) { + return true + } + return !isPathCandidateContinuationChar(output[start - 1]!) +} + +function isPathCandidateEndBoundary(output: string, end: number): boolean { + const next = output[end] + if (!next) { + return true + } + if (next === ':' && /^\d+(?::\d+)?(?:\D|$)/.test(output.slice(end + 1))) { + return true + } + return !isPathCandidateContinuationChar(next) +} + +function isPathCandidateContinuationChar(char: string): boolean { + return /[A-Za-z0-9._~/%+@\\()[\]-]/.test(char) +} + +function decodeTerminalOutputPercentEscapes(value: string): string { + return value.replace(/(?:%[0-9a-f]{2})+/gi, (match) => { + try { + return decodeURIComponent(match) + } catch { + return match + } + }) +} + +// Why: the extension regex backtracks quadratically on the PTY hot path; candidates can't cross a newline, so scan per line and skip over-long lines (dropped anyway). +function extractTerminalOutputPathCandidates(data: string): string[] { + const candidates: string[] = [] + const add = (value: string): void => { + const candidate = trimTerminalOutputPathCandidate(value) + if (candidate.length > 0) { + candidates.push(candidate) + const drivePath = normalizeTerminalOutputFileUriDrivePath(candidate) + if (drivePath) { + candidates.push(drivePath) + } + } + } + for (const line of data.split(/[\r\n]+/)) { + if (line.length === 0 || line.length > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { + continue + } + collectTerminalOutputLinePathCandidates(line, add) + } + return candidates +} + +function collectTerminalOutputLinePathCandidates(line: string, add: (value: string) => void): void { + for (const match of line.matchAll(/file:\/\/([^/\s]*)(\/[^\s\x1b"'<>)]*)/gi)) { + const authority = match[1] ?? '' + const uriPath = match[2] + if (uriPath) { + const decoded = decodeTerminalOutputPercentEscapes(uriPath) + add(isTerminalOutputLoopbackAuthority(authority) ? decoded : `//${authority}${decoded}`) + } + } + for (const match of line.matchAll( + /(?:\/(?:tmp|private\/tmp)\/|[A-Za-z]:[\\/])[^\r\n\x1b"'<>]+/g + )) { + if (isInsideNonLocalFileUri(line, match.index)) { + continue + } + add(match[0]) + } + for (const match of line.matchAll( + /\/[^\r\n\x1b"'<>]*\.[A-Za-z0-9_+-]+(?:[#:\s][^\r\n\x1b"'<>]*)?/g + )) { + if (isInsideNonLocalFileUri(line, match.index)) { + continue + } + add(match[0]) + } +} + +function normalizeTerminalOutputFileUriDrivePath(candidate: string): string | null { + return /^\/[A-Za-z]:[\\/]/.test(candidate) ? candidate.slice(1) : null +} + +function trimTerminalOutputPathCandidate(value: string): string { + let candidate = value.trim().replace(/[),;.]+$/g, '') + if (Buffer.byteLength(candidate, 'utf8') > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { + return '' + } + let selected: string | null = null + for (const match of candidate.matchAll( + /.+?\.[A-Za-z0-9_+-]+(?:#L\d+(?:C\d+)?|(?::\d+)?(?::\d+)?)?(?=\s+|$)/gi + )) { + const end = match.index + match[0].length + const text = candidate.slice(0, end) + if (countTerminalOutputPathStarts(text) > 1) { + continue + } + // Same as the tap parsers: a line-end token extends the candidate only when the added segment is path-like, so trailing prose isn't swallowed. + if ( + end < candidate.length || + selected === null || + /[\\/]/.test(candidate.slice(selected.length, end)) + ) { + selected = text + } + } + return trimTerminalOutputPathLocator(selected ?? candidate) +} + +function isTerminalOutputLoopbackAuthority(authority: string): boolean { + const normalized = authority.toLowerCase() + return ( + normalized === '' || + normalized === 'localhost' || + normalized === '127.0.0.1' || + normalized === '::1' || + normalized === '[::1]' + ) +} + +function isInsideNonLocalFileUri(output: string, pathStart: number): boolean { + const prefix = output.slice(0, pathStart) + const match = /file:\/\/([^/\s]*)$/i.exec(prefix) + return !!match && !isTerminalOutputLoopbackAuthority(match[1] ?? '') +} + +function countTerminalOutputPathStarts(value: string): number { + let count = 0 + for (const match of value.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) { + void match + count += 1 + } + return count +} + +function trimTerminalOutputPathLocator(value: string): string { + return value.replace(/#L\d+(?:C\d+)?$/i, '').replace(/:\d+(?::\d+)?$/, '') +} + +function wslTerminalOutputAliases(value: string): string[] { + const match = /^\\\\wsl(?:\.localhost|\$)\\[^\\]+(\\.*)$/i.exec(value) + if (!match) { + return [] + } + const linuxPath = match[1]!.replace(/\\/g, '/') + return linuxPath.startsWith('/') ? [linuxPath] : [`/${linuxPath}`] +} diff --git a/src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts b/src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts index 3c9d94109a8..e526b64f3c9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts @@ -45,36 +45,48 @@ describe('terminal-fit-restore', () => { }) it('restores remote terminals through the environment runtime RPC', async () => { - vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue('terminal-one') - vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReturnValue('env-one') - vi.mocked(callRuntimeRpc).mockResolvedValue({ restored: true }) + // Why: freeze Date.now so remaining deadline is exact (wall clock can drop 1ms). + vi.useFakeTimers() + try { + vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue('terminal-one') + vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReturnValue('env-one') + vi.mocked(callRuntimeRpc).mockResolvedValue({ restored: true }) - await expect(restoreTerminalFitToDesktop('remote:pty-1', undefined)).resolves.toBe(true) + await expect(restoreTerminalFitToDesktop('remote:pty-1', undefined)).resolves.toBe(true) - expect(callRuntimeRpc).toHaveBeenCalledWith( - { kind: 'environment', environmentId: 'env-one' }, - 'terminal.restoreFit', - { terminal: 'terminal-one' }, - { timeoutMs: 15_000 } - ) - expect(restoreTerminalFit).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-one' }, + 'terminal.restoreFit', + { terminal: 'terminal-one' }, + { timeoutMs: 15_000 } + ) + expect(restoreTerminalFit).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } }) it('uses the active runtime environment when the remote PTY has no encoded environment', async () => { - vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue('terminal-two') - vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReturnValue(null) - vi.mocked(callRuntimeRpc).mockResolvedValue({ restored: true }) + // Why: freeze Date.now so remaining deadline is exact (wall clock can drop 1ms). + vi.useFakeTimers() + try { + vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue('terminal-two') + vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReturnValue(null) + vi.mocked(callRuntimeRpc).mockResolvedValue({ restored: true }) - await expect( - restoreTerminalFitToDesktop('remote:pty-2', { activeRuntimeEnvironmentId: 'env-active' }) - ).resolves.toBe(true) + await expect( + restoreTerminalFitToDesktop('remote:pty-2', { activeRuntimeEnvironmentId: 'env-active' }) + ).resolves.toBe(true) - expect(callRuntimeRpc).toHaveBeenCalledWith( - { kind: 'environment', environmentId: 'env-active' }, - 'terminal.restoreFit', - { terminal: 'terminal-two' }, - { timeoutMs: 15_000 } - ) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-active' }, + 'terminal.restoreFit', + { terminal: 'terminal-two' }, + { timeoutMs: 15_000 } + ) + } finally { + vi.useRealTimers() + } }) it('deduplicates bulk restore PTYs and succeeds when any restore succeeds', async () => { From abd160fadda6c93c5a92da29476f93eeb3906354 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:43:58 -0700 Subject: [PATCH 034/271] perf(renderer): defer terminal quick command host work (#13517) --- .../components/terminal-pane/TerminalPane.tsx | 46 +++++---- .../use-terminal-quick-command-hosts.test.ts | 53 ++++++++++- .../hooks/use-terminal-quick-command-hosts.ts | 93 ++++++++++++++----- 3 files changed, 146 insertions(+), 46 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index d0e9dee537d..28d36689bcf 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -824,26 +824,6 @@ function TerminalPane( : quickCommandRepoId ? 'This Repo' : null - const { - hosts: quickCommandHosts, - refreshRemoteHost: refreshQuickCommandRemoteHost, - remoteHostLoadFailed: quickCommandHostLoadFailed, - remoteHostPending: quickCommandHostOwnershipPending - } = useTerminalQuickCommandHosts(worktreeId) - const visibleQuickCommandHosts = quickCommandHosts.map((host) => { - const commands = host.commands.filter(isTerminalQuickCommandComplete) - return { - globalCommands: commands.filter( - (command) => getTerminalQuickCommandScope(command).type === 'global' - ), - hostId: host.hostId, - label: host.label, - repoCommands: commands.filter((command) => { - const scope = getTerminalQuickCommandScope(command) - return scope.type === 'repo' && terminalQuickCommandMatchesRepo(command, quickCommandRepoId) - }) - } - }) const quickCommandGroupId = useAppStore( (s) => @@ -2569,6 +2549,32 @@ function TerminalPane( forceBracketedMultilineTextPaste, rightClickToPaste }) + const { + hosts: quickCommandHosts, + refreshRemoteHost: refreshQuickCommandRemoteHost, + remoteHostLoadFailed: quickCommandHostLoadFailed, + remoteHostPending: quickCommandHostOwnershipPending + } = useTerminalQuickCommandHosts(worktreeId, contextMenu.open) + const visibleQuickCommandHosts = useMemo( + () => + quickCommandHosts.map((host) => { + const commands = host.commands.filter(isTerminalQuickCommandComplete) + return { + globalCommands: commands.filter( + (command) => getTerminalQuickCommandScope(command).type === 'global' + ), + hostId: host.hostId, + label: host.label, + repoCommands: commands.filter((command) => { + const scope = getTerminalQuickCommandScope(command) + return ( + scope.type === 'repo' && terminalQuickCommandMatchesRepo(command, quickCommandRepoId) + ) + }) + } + }), + [quickCommandHosts, quickCommandRepoId] + ) useEffect(() => { if (contextMenu.open) { refreshQuickCommandRemoteHost() diff --git a/src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts b/src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts index 082683dde96..c3014b35d8c 100644 --- a/src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts +++ b/src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts @@ -11,18 +11,23 @@ import type { RuntimeTerminalQuickCommands } from '@/store/slices/terminal-quick const testState = vi.hoisted(() => ({ executionHostId: 'runtime:build' as ExecutionHostId, loadRuntimeTerminalQuickCommands: vi.fn(async () => {}), + resolveExecutionHostId: vi.fn(() => 'runtime:build' as ExecutionHostId), runtimeEnvironments: [] as { id: string; name: string }[], runtimeStatusByEnvironmentId: new Map(), runtimeTerminalQuickCommands: new Map(), - settings: null as GlobalSettings | null + settings: null as GlobalSettings | null, + subscribedSelectors: [] as ((state: unknown) => unknown)[] })) vi.mock('@/store', () => ({ - useAppStore: (selector: (state: typeof testState) => unknown) => selector(testState) + useAppStore: (selector: (state: typeof testState) => unknown) => { + testState.subscribedSelectors.push(selector as (state: unknown) => unknown) + return selector(testState) + } })) vi.mock('@/lib/worktree-runtime-owner', () => ({ - getExecutionHostIdForWorktree: () => testState.executionHostId + getExecutionHostIdForWorktree: () => testState.resolveExecutionHostId() })) import { @@ -34,12 +39,16 @@ import { } from './use-terminal-quick-command-hosts' let renderedHosts: TerminalQuickCommandHost[] = [] +let renderedExecutionHostId: ExecutionHostId = 'local' +let refreshRemoteHost = (): void => {} let remoteHostLoadFailed = false let remoteHostPending = false -function Probe(): null { - const result = useTerminalQuickCommandHosts('worktree-1') +function Probe({ enabled = true }: { enabled?: boolean }): null { + const result = useTerminalQuickCommandHosts('worktree-1', enabled) + renderedExecutionHostId = result.executionHostId renderedHosts = result.hosts + refreshRemoteHost = result.refreshRemoteHost remoteHostLoadFailed = result.remoteHostLoadFailed remoteHostPending = result.remoteHostPending return null @@ -51,10 +60,13 @@ describe('useTerminalQuickCommandHosts', () => { beforeEach(() => { testState.executionHostId = 'runtime:build' testState.loadRuntimeTerminalQuickCommands.mockClear() + testState.resolveExecutionHostId.mockClear() + testState.resolveExecutionHostId.mockImplementation(() => testState.executionHostId) testState.runtimeEnvironments = [{ id: 'build', name: 'Build Server' }] testState.runtimeStatusByEnvironmentId = new Map([['build', { connectionGeneration: 4 }]]) testState.runtimeTerminalQuickCommands = new Map() testState.settings = getDefaultSettings('/tmp') + testState.subscribedSelectors = [] renderedHosts = [] remoteHostLoadFailed = false remoteHostPending = false @@ -75,6 +87,37 @@ describe('useTerminalQuickCommandHosts', () => { ).toBe(true) }) + it('skips host resolution and remote loading while disabled', async () => { + await act(async () => root.render(createElement(Probe, { enabled: false }))) + + const disabledHosts = renderedHosts + expect(testState.subscribedSelectors).toHaveLength(1) + for (let write = 0; write < 1_000; write += 1) { + testState.subscribedSelectors[0](testState) + } + refreshRemoteHost() + + expect(renderedExecutionHostId).toBe('local') + expect(renderedHosts).toEqual([]) + expect(remoteHostLoadFailed).toBe(false) + expect(remoteHostPending).toBe(false) + expect(testState.resolveExecutionHostId).not.toHaveBeenCalled() + expect(testState.loadRuntimeTerminalQuickCommands).not.toHaveBeenCalled() + + testState.executionHostId = 'runtime:other' + await act(async () => root.render(createElement(Probe, { enabled: false }))) + + expect(renderedHosts).toBe(disabledHosts) + expect(testState.resolveExecutionHostId).not.toHaveBeenCalled() + expect(testState.loadRuntimeTerminalQuickCommands).not.toHaveBeenCalled() + + testState.executionHostId = 'runtime:build' + await act(async () => root.render(createElement(Probe))) + + expect(testState.resolveExecutionHostId).toHaveBeenCalledOnce() + expect(testState.loadRuntimeTerminalQuickCommands).toHaveBeenCalledWith('build') + }) + it.each([ { name: 'unsupported', diff --git a/src/renderer/src/hooks/use-terminal-quick-command-hosts.ts b/src/renderer/src/hooks/use-terminal-quick-command-hosts.ts index a70e0e750ba..f8239f1dcb4 100644 --- a/src/renderer/src/hooks/use-terminal-quick-command-hosts.ts +++ b/src/renderer/src/hooks/use-terminal-quick-command-hosts.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' import type { GlobalSettings, TerminalQuickCommand } from '../../../shared/types' import { LOCAL_EXECUTION_HOST_ID, @@ -9,6 +10,10 @@ import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overr import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry' import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' import { useAppStore } from '@/store' +import type { + RuntimeTerminalQuickCommands, + TerminalQuickCommandHostsSlice +} from '@/store/slices/terminal-quick-command-hosts' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' export type TerminalQuickCommandHost = { @@ -31,6 +36,30 @@ export type HostedTerminalQuickCommand = { key: string } +type TerminalQuickCommandHostState = { + executionHostId: ExecutionHostId + loadRemote: TerminalQuickCommandHostsSlice['loadRuntimeTerminalQuickCommands'] | null + remoteConnectionGeneration: number + remoteEnvironmentId: string | null + remoteHostId: ExecutionHostId | null + remoteState: RuntimeTerminalQuickCommands | undefined + runtimeEnvironments: PublicKnownRuntimeEnvironment[] + settings: GlobalSettings | null +} + +const EMPTY_RUNTIME_ENVIRONMENTS: PublicKnownRuntimeEnvironment[] = [] +const DISABLED_TERMINAL_QUICK_COMMAND_HOSTS: TerminalQuickCommandHost[] = [] +const DISABLED_TERMINAL_QUICK_COMMAND_HOST_STATE: TerminalQuickCommandHostState = { + executionHostId: LOCAL_EXECUTION_HOST_ID, + loadRemote: null, + remoteConnectionGeneration: 0, + remoteEnvironmentId: null, + remoteHostId: null, + remoteState: undefined, + runtimeEnvironments: EMPTY_RUNTIME_ENVIRONMENTS, + settings: null +} + export function getHostedTerminalQuickCommandKey( hostId: ExecutionHostId, commandId: string @@ -68,41 +97,59 @@ export function getTerminalQuickCommandHostOptions( }).map((host) => ({ id: host.id, label: host.label })) } -export function useTerminalQuickCommandHosts(worktreeId: string): { +export function useTerminalQuickCommandHosts( + worktreeId: string, + enabled = true +): { executionHostId: ExecutionHostId hosts: TerminalQuickCommandHost[] refreshRemoteHost: () => void remoteHostLoadFailed: boolean remoteHostPending: boolean } { - const executionHostId = useAppStore((state) => getExecutionHostIdForWorktree(state, worktreeId)) - const settings = useAppStore((state) => state.settings) - const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments) - const remoteState = useAppStore((state) => { - const parsed = parseExecutionHostId(executionHostId) - return parsed?.kind === 'runtime' - ? state.runtimeTerminalQuickCommands.get(parsed.environmentId) - : undefined - }) - const loadRemote = useAppStore((state) => state.loadRuntimeTerminalQuickCommands) - const parsedExecutionHost = parseExecutionHostId(executionHostId) - const remoteHostId = parsedExecutionHost?.kind === 'runtime' ? parsedExecutionHost.id : null - const remoteEnvironmentId = - parsedExecutionHost?.kind === 'runtime' ? parsedExecutionHost.environmentId : null - const remoteConnectionGeneration = useAppStore((state) => - remoteEnvironmentId - ? (state.runtimeStatusByEnvironmentId.get(remoteEnvironmentId)?.connectionGeneration ?? 0) - : 0 + const { + executionHostId, + loadRemote, + remoteConnectionGeneration, + remoteEnvironmentId, + remoteHostId, + remoteState, + runtimeEnvironments, + settings + } = useAppStore( + useShallow((state): TerminalQuickCommandHostState => { + if (!enabled) { + return DISABLED_TERMINAL_QUICK_COMMAND_HOST_STATE + } + const executionHostId = getExecutionHostIdForWorktree(state, worktreeId) + const parsedExecutionHost = parseExecutionHostId(executionHostId) + const remoteEnvironmentId = + parsedExecutionHost?.kind === 'runtime' ? parsedExecutionHost.environmentId : null + return { + executionHostId, + loadRemote: state.loadRuntimeTerminalQuickCommands, + remoteConnectionGeneration: remoteEnvironmentId + ? (state.runtimeStatusByEnvironmentId.get(remoteEnvironmentId)?.connectionGeneration ?? 0) + : 0, + remoteEnvironmentId, + remoteHostId: parsedExecutionHost?.kind === 'runtime' ? parsedExecutionHost.id : null, + remoteState: remoteEnvironmentId + ? state.runtimeTerminalQuickCommands.get(remoteEnvironmentId) + : undefined, + runtimeEnvironments: state.runtimeEnvironments, + settings: state.settings + } + }) ) useEffect(() => { - if (remoteEnvironmentId) { + if (loadRemote && remoteEnvironmentId) { void loadRemote(remoteEnvironmentId) } }, [loadRemote, remoteConnectionGeneration, remoteEnvironmentId]) const refreshRemoteHost = useCallback((): void => { - if (remoteEnvironmentId) { + if (loadRemote && remoteEnvironmentId) { void loadRemote(remoteEnvironmentId, { force: true }) } }, [loadRemote, remoteEnvironmentId]) @@ -122,6 +169,9 @@ export function useTerminalQuickCommandHosts(worktreeId: string): { ) const hosts = useMemo(() => { + if (!enabled) { + return DISABLED_TERMINAL_QUICK_COMMAND_HOSTS + } const hostOptions = getTerminalQuickCommandHostOptions(settings, runtimeEnvironments) const result: TerminalQuickCommandHost[] = [ { @@ -146,6 +196,7 @@ export function useTerminalQuickCommandHosts(worktreeId: string): { }) return result }, [ + enabled, remoteConnectionGeneration, remoteEnvironmentId, remoteHostId, From 0fa3a5bd1dda5606a059d121e694b0c7b58fa787 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:16:21 -0700 Subject: [PATCH 035/271] Show directional lineage in agent map (#13503) * Show directional lineage in agent map * Route agent lineage links diagonally * Guard agent map lineage path bounds * Avoid chevrons in undersized lineage gaps --- .../AgentDashboardToolbar.tsx | 2 +- .../dashboard-popout/AgentMap.test.tsx | 2 + .../dashboard-popout/AgentMapScene.tsx | 11 +-- .../AgentMapWorktreeRingNode.tsx | 10 +- .../agent-map-lineage-chevron-path.test.ts | 46 ++++++++++ .../agent-map-lineage-chevron-path.ts | 92 +++++++++++++++++++ .../components/dashboard-popout/agent-map.css | 1 - 7 files changed, 145 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx index 53fa18a7398..016b96bad94 100644 --- a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx @@ -48,7 +48,7 @@ type AgentDashboardToolbarProps = { showAgentlessWorkspaces?: boolean agentlessWorkspaceCount?: number onShowAgentlessWorkspacesChange?: (show: boolean) => void - /** Map-only: the dashed parent→child dispatch edges. */ + /** Map-only: the directional parent→child dispatch edges. */ showOrchestrationLinks?: boolean onShowOrchestrationLinksChange?: (show: boolean) => void searchInputRef: React.RefObject diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx index 60f587bc907..5b1d4a0da6d 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -361,11 +361,13 @@ describe('AgentMap', () => { expect(workerLink).toHaveClass('agent-map-lineage-link') expect(workerLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') expect(workerLink).toHaveAttribute('data-parent-pane-key', 'parent') + expect(workerLink?.getAttribute('d')?.match(/\bM\b/g)?.length).toBeGreaterThan(1) // Why orchestration, not subagent: `nested` is a card, and in-process subagents // never become cards — so a grandchild dispatch is still an orchestration edge. expect(nestedLink).toHaveClass('agent-map-lineage-link') expect(nestedLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') expect(nestedLink).toHaveAttribute('data-parent-pane-key', 'child') + expect(nestedLink?.getAttribute('d')?.match(/\bM\b/g)?.length).toBeGreaterThan(1) }) it('connects spawned workers across worktree rings', () => { diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx index 0dc09b6f0dd..251e7181802 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -12,6 +12,7 @@ import type { } from './agent-map-layout' import { AGENT_MAP_LINEAGE_RELATION, shouldAggregateAgentMapWorktree } from './agent-map-layout' import { selectVisibleAgentMapLabels } from './agent-map-label-declutter' +import { agentMapDirectLineageChevronPath } from './agent-map-lineage-chevron-path' import { AgentMapWorktreeLabel } from './AgentMapWorktreeLabel' import { AgentMapWorktreeRingNode } from './AgentMapWorktreeRingNode' @@ -52,15 +53,7 @@ type VisibleAgentLocation = { } function agentLineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string { - const dx = child.x - parent.x - const dy = child.y - parent.y - const distance = Math.hypot(dx, dy) - if (distance === 0) { - return `M ${parent.x} ${parent.y}` - } - const unitX = dx / distance - const unitY = dy / distance - return `M ${parent.x + unitX * parent.radius} ${parent.y + unitY * parent.radius} L ${child.x - unitX * child.radius} ${child.y - unitY * child.radius}` + return agentMapDirectLineageChevronPath(parent, child) } /** Memoization keeps pointer panning to one SVG viewBox write, not a map rerender. */ diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index 72d5875c801..9642cf16155 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -14,6 +14,7 @@ import type { AgentMapWorktreeRing } from './agent-map-layout' import { AGENT_MAP_LINEAGE_RELATION, shouldAggregateAgentMapWorktree } from './agent-map-layout' +import { agentMapDirectLineageChevronPath } from './agent-map-lineage-chevron-path' import { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status' type AgentMapWorktreeRingNodeProps = { @@ -50,15 +51,8 @@ function formatDuration(minutes: number): string { }) } -// Why direction-aware: packing can place a child level with or above its parent, and -// a fixed downward elbow would then exit the wrong side and draw back through both -// nodes. Leaving the rank tie at +1 keeps the common downward case byte-identical. function lineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string { - const direction = child.y < parent.y ? -1 : 1 - const startY = parent.y + parent.radius * direction - const endY = child.y - child.radius * direction - const branchY = (startY + endY) / 2 - return `M ${parent.x} ${startY} L ${parent.x} ${branchY} L ${child.x} ${branchY} L ${child.x} ${endY}` + return agentMapDirectLineageChevronPath(parent, child) } function agentName(card: DashboardCard): string { diff --git a/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts new file mode 100644 index 00000000000..193651eaf28 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { agentMapDirectLineageChevronPath } from './agent-map-lineage-chevron-path' + +describe('agentMapDirectLineageChevronPath', () => { + it('runs every chevron directly from the parent toward the child', () => { + const path = agentMapDirectLineageChevronPath( + { x: 0, y: 0, radius: 4 }, + { x: 40, y: 40, radius: 4 } + ) + const tips = [...path.matchAll(/M [-\d.]+ [-\d.]+ L ([-\d.]+) ([-\d.]+) L/g)].map((match) => ({ + x: Number(match[1]), + y: Number(match[2]) + })) + + expect(tips.length).toBeGreaterThan(1) + expect(tips.every((tip) => tip.x === tip.y)).toBe(true) + expect(tips.at(-1)?.x).toBeGreaterThan(tips[0].x) + }) + + it('trims the path to unequal node radii', () => { + expect( + agentMapDirectLineageChevronPath({ x: 0, y: 0, radius: 2 }, { x: 20, y: 0, radius: 6 }) + ).toBe('M 4.5 2.25 L 8 0 L 4.5 -2.25') + }) + + it('does not reverse direction when node boundaries overlap', () => { + expect( + agentMapDirectLineageChevronPath({ x: 0, y: 0, radius: 10 }, { x: 15, y: 0, radius: 10 }) + ).toBe('M 0 0') + }) + + it('omits a chevron that cannot fit between trimmed node boundaries', () => { + expect( + agentMapDirectLineageChevronPath({ x: 0, y: 0, radius: 10 }, { x: 25, y: 0, radius: 10 }) + ).toBe('M 10 0') + }) + + it('caps decorative chevrons on long links', () => { + const path = agentMapDirectLineageChevronPath( + { x: 0, y: 0, radius: 0 }, + { x: 10_000, y: 0, radius: 0 } + ) + + expect(path.match(/\bM\b/g)).toHaveLength(32) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts new file mode 100644 index 00000000000..f1602805b2b --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts @@ -0,0 +1,92 @@ +export type AgentMapLineagePoint = { + x: number + y: number +} + +type AgentMapLineageNode = AgentMapLineagePoint & { + radius: number +} + +type LineageSegment = { + start: AgentMapLineagePoint + unitX: number + unitY: number + length: number +} + +const CHEVRON_SPACING = 8 +const CHEVRON_DEPTH = 3.5 +const CHEVRON_HALF_WIDTH = 2.25 +const MAX_CHEVRONS_PER_PATH = 32 + +function svgNumber(value: number): number { + return Math.round(value * 1_000) / 1_000 +} + +export function agentMapLineageChevronPath(points: AgentMapLineagePoint[]): string { + const segments: LineageSegment[] = [] + let totalLength = 0 + for (let index = 1; index < points.length; index += 1) { + const start = points[index - 1] + const end = points[index] + const dx = end.x - start.x + const dy = end.y - start.y + const length = Math.hypot(dx, dy) + if (length === 0) { + continue + } + segments.push({ start, unitX: dx / length, unitY: dy / length, length }) + totalLength += length + } + if (segments.length === 0 || totalLength < CHEVRON_DEPTH * 2) { + return points[0] ? `M ${svgNumber(points[0].x)} ${svgNumber(points[0].y)}` : '' + } + + const chevronCount = Math.min( + MAX_CHEVRONS_PER_PATH, + Math.max(1, Math.floor(totalLength / CHEVRON_SPACING)) + ) + const commands: string[] = [] + let segmentIndex = 0 + let segmentStartDistance = 0 + for (let index = 0; index < chevronCount; index += 1) { + const distance = (totalLength * (index + 1)) / (chevronCount + 1) + while ( + segmentIndex < segments.length - 1 && + distance > segmentStartDistance + segments[segmentIndex].length + ) { + segmentStartDistance += segments[segmentIndex].length + segmentIndex += 1 + } + const segment = segments[segmentIndex] + const offset = distance - segmentStartDistance + const tipX = segment.start.x + segment.unitX * offset + const tipY = segment.start.y + segment.unitY * offset + const backX = tipX - segment.unitX * CHEVRON_DEPTH + const backY = tipY - segment.unitY * CHEVRON_DEPTH + const perpendicularX = -segment.unitY * CHEVRON_HALF_WIDTH + const perpendicularY = segment.unitX * CHEVRON_HALF_WIDTH + commands.push( + `M ${svgNumber(backX + perpendicularX)} ${svgNumber(backY + perpendicularY)} L ${svgNumber(tipX)} ${svgNumber(tipY)} L ${svgNumber(backX - perpendicularX)} ${svgNumber(backY - perpendicularY)}` + ) + } + return commands.join(' ') +} + +export function agentMapDirectLineageChevronPath( + parent: AgentMapLineageNode, + child: AgentMapLineageNode +): string { + const dx = child.x - parent.x + const dy = child.y - parent.y + const distance = Math.hypot(dx, dy) + if (distance <= parent.radius + child.radius) { + return agentMapLineageChevronPath([parent]) + } + const unitX = dx / distance + const unitY = dy / distance + return agentMapLineageChevronPath([ + { x: parent.x + unitX * parent.radius, y: parent.y + unitY * parent.radius }, + { x: child.x - unitX * child.radius, y: child.y - unitY * child.radius } + ]) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css index 12e2ad578a2..394c28b715b 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -198,7 +198,6 @@ fill: none; pointer-events: none; stroke: color-mix(in srgb, var(--muted-foreground) 42%, transparent); - stroke-dasharray: 1 4; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1; From 8a3f704bd1c65bc4cb959deff0a5f1396f6d3b08 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:16:54 -0700 Subject: [PATCH 036/271] fix(agent-dashboard): highlight agents awaiting input (#13523) * fix(agent-dashboard): highlight agents awaiting input * test(agent-dashboard): cover status halo boundaries --- .../dashboard-popout/AgentMap.test.tsx | 31 ++++++++++++++++--- .../AgentMapWorktreeRingNode.tsx | 11 +++++-- .../agent-map-glow.performance.test.ts | 16 +++++++--- .../components/dashboard-popout/agent-map.css | 15 +++++++-- 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx index 5b1d4a0da6d..a100ecaabef 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -164,6 +164,23 @@ describe('AgentMap', () => { expect(doneNode).toHaveAccessibleName(/unread/) }) + it.each([ + { bucket: 'working', dotState: 'working', unseen: false, glows: true }, + { bucket: 'attention', dotState: 'waiting', unseen: false, glows: true }, + { bucket: 'attention', dotState: 'blocked', unseen: false, glows: true }, + { bucket: 'done', dotState: 'done', unseen: true, glows: false }, + { bucket: 'idle', dotState: 'idle', unseen: false, glows: false } + ] as const)('applies the expected halo for $dotState agents', (state) => { + const { container } = renderMap([card(state)]) + const glow = container.querySelector('[data-agent-map-agent-status-glow]') + + if (state.glows) { + expect(glow).toHaveAttribute('data-agent-active-status', state.dotState) + return + } + expect(glow).not.toBeInTheDocument() + }) + it('lets attention override working on the worktree glow', () => { const done = card({ paneKey: 'done', @@ -192,9 +209,15 @@ describe('AgentMap', () => { name: /Open Finished worktree worktree details/ }) - expect(workingNode.querySelector('[data-agent-map-agent-working-glow]')).toBeInTheDocument() - expect(waitingNode.querySelector('[data-agent-map-agent-working-glow]')).not.toBeInTheDocument() - expect(doneNode.querySelector('[data-agent-map-agent-working-glow]')).not.toBeInTheDocument() + expect(workingNode.querySelector('[data-agent-map-agent-status-glow]')).toHaveAttribute( + 'data-agent-active-status', + 'working' + ) + expect(waitingNode.querySelector('[data-agent-map-agent-status-glow]')).toHaveAttribute( + 'data-agent-active-status', + 'waiting' + ) + expect(doneNode.querySelector('[data-agent-map-agent-status-glow]')).not.toBeInTheDocument() expect(workingRing).toHaveClass('is-waiting') expect(workingRing).not.toHaveClass('is-working') expect(doneRing).not.toHaveClass('is-working') @@ -221,7 +244,7 @@ describe('AgentMap', () => { }) const { container } = renderMap(cards, { selectedPaneKey: 'pane-0' }) - expect(container.querySelectorAll('[data-agent-map-agent-working-glow]')).toHaveLength(60) + expect(container.querySelectorAll('[data-agent-map-agent-status-glow]')).toHaveLength(60) expect(container.querySelectorAll('[data-agent-map-worktree-status-glow]')).toHaveLength(1) expect(container.querySelectorAll('filter')).toHaveLength(0) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index 9642cf16155..cd668275a3a 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -297,6 +297,10 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ {worktree.agents.map((agent) => { const iconSize = Math.max(12, Math.min(22, agent.radius * 1.05)) + const hasStatusGlow = + agent.status === 'working' || + agent.status === 'waiting' || + agent.status === 'blocked' return ( onAgentKeyDown(event, agent)} > - {agent.status === 'working' ? ( + {hasStatusGlow ? (
- + />
) diff --git a/src/renderer/src/components/feature-wall/AiCommitPrSettingsSwitch.tsx b/src/renderer/src/components/feature-wall/AiCommitPrSettingsSwitch.tsx index 751722d95ff..767bcf85439 100644 --- a/src/renderer/src/components/feature-wall/AiCommitPrSettingsSwitch.tsx +++ b/src/renderer/src/components/feature-wall/AiCommitPrSettingsSwitch.tsx @@ -1,5 +1,5 @@ import type { JSX } from 'react' -import { cn } from '@/lib/utils' +import { Switch } from '@/components/ui/switch' type AiCommitPrSettingsSwitchProps = { checked: boolean @@ -12,24 +12,5 @@ export function AiCommitPrSettingsSwitch({ label, onToggle }: AiCommitPrSettingsSwitchProps): JSX.Element { - return ( - - ) + return } diff --git a/src/renderer/src/components/feature-wall/KeepAwakeCard.test.tsx b/src/renderer/src/components/feature-wall/KeepAwakeCard.test.tsx new file mode 100644 index 00000000000..81acd44cb20 --- /dev/null +++ b/src/renderer/src/components/feature-wall/KeepAwakeCard.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { getAgentAwakeTitle } from '../settings/agent-awake-copy' +import { KeepAwakeCard } from './KeepAwakeCard' + +afterEach(cleanup) + +describe('KeepAwakeCard', () => { + it('enables automatic keep-awake mode from off', () => { + const updateSettings = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('switch', { name: getAgentAwakeTitle() })) + + expect(updateSettings).toHaveBeenCalledWith({ + computerAwakeMode: 'auto', + keepComputerAwakeWhileAgentsRun: true + }) + }) + + it('disables either enabled mode', () => { + const updateSettings = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('switch', { name: getAgentAwakeTitle() })) + + expect(updateSettings).toHaveBeenCalledWith({ + computerAwakeMode: 'off', + keepComputerAwakeWhileAgentsRun: false + }) + }) +}) diff --git a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx index 40d25ebf8bb..a201b6f60cd 100644 --- a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx +++ b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx @@ -1,5 +1,5 @@ import type { JSX } from 'react' -import { cn } from '@/lib/utils' +import { Switch } from '@/components/ui/switch' import { getAgentAwakeDescription, getAgentAwakeTitle } from '../settings/agent-awake-copy' import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' @@ -33,23 +33,13 @@ export function KeepAwakeCard(props: { {getAgentAwakeDescription()}

- + checked={enabled} + onCheckedChange={(checked) => + updateSettings(computerAwakeSettingsForMode(checked ? 'auto' : 'off')) + } + /> ) diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 0a3dd5ae786..81dd540ff5f 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -19,6 +19,7 @@ import { Label } from '../ui/label' import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' import { Separator } from '../ui/separator' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { Switch } from '../ui/switch' import { AlertTriangle, ExternalLink, @@ -1555,25 +1556,19 @@ export function AccountsPane({ )}

- + /> ) : null, diff --git a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx index 2cbdf227e0d..d31d9b69533 100644 --- a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx +++ b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx @@ -18,6 +18,7 @@ import { SourceControlActionVariableChips } from '../source-control/SourceContro import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible' import { Label } from '../ui/label' import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' +import { Switch } from '../ui/switch' import { getAutoRenameBranchAdvancedSearchEntries } from './auto-rename-branch-search' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search' @@ -172,24 +173,18 @@ export function AutoRenameBranchFromWorkSetting({ )}

- + /> diff --git a/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx b/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx index b26c1e2fd78..fa7f26f135b 100644 --- a/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx +++ b/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx @@ -1,5 +1,6 @@ import type { GlobalSettings } from '../../../../shared/types' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { SearchableSetting } from './SearchableSetting' import { translate } from '@/i18n/i18n' @@ -39,25 +40,16 @@ export function BrowserLinkRoutingSetting({

{linkRoutingDescription}

- + /> ) } diff --git a/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx b/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx index 28a071b1583..20995d90239 100644 --- a/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx +++ b/src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx @@ -1,4 +1,5 @@ import { translate } from '@/i18n/i18n' +import { Switch } from '@/components/ui/switch' export function BrowserUseEnableSwitch({ enabled, onToggle @@ -7,23 +8,13 @@ export function BrowserUseEnableSwitch({ onToggle: () => void }): React.JSX.Element { return ( - + onCheckedChange={onToggle} + /> ) } diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 86a5c89f346..40f575fd780 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -20,6 +20,7 @@ import { import { useMountedRef } from '@/hooks/useMountedRef' import { Button } from '../ui/button' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { CliRegistrationDialog } from './CliRegistrationDialog' @@ -288,21 +289,16 @@ export function CliSection({ {!isBrowserManaged ? ( - + onCheckedChange={() => setDialogOpen(true)} + className="disabled:opacity-60" + /> ) : null} diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index 13f1b10cf52..ecc01cc4c11 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -21,6 +21,7 @@ import { getRuntimeGitScope } from '../../runtime/runtime-git-client' import { useAppStore } from '../../store' import { Input } from '../ui/input' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { SearchableSetting } from './SearchableSetting' import { SourceControlAiActionRecipeDefaults } from './SourceControlAiActionRecipeDefaults' import { matchesSettingsSearch } from './settings-search' @@ -199,20 +200,14 @@ export function CommitMessageAiPane({ )}

- + ) } diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 9760591d477..25c161d0da9 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -1,5 +1,6 @@ import type { GlobalSettings } from '../../../../shared/types' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { useAppStore } from '../../store' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' @@ -86,23 +87,13 @@ export function ExperimentalPane({ )}

- + /> ) : null} @@ -129,25 +120,18 @@ export function ExperimentalPane({ )}

- + /> ) : null} @@ -188,25 +172,18 @@ export function ExperimentalPane({ )}

- + /> ) : null} diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx index 490cef11e36..ccf1befdeb7 100644 --- a/src/renderer/src/components/settings/GitPane.tsx +++ b/src/renderer/src/components/settings/GitPane.tsx @@ -4,6 +4,7 @@ import type { SourceControlAiSettingsPatch } from '../../../../shared/source-con import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from '../../../../shared/source-control-group-order' import { Input } from '../ui/input' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { useAppStore } from '../../store' import { getGitPaneSearchEntries } from './git-search' import { SearchableSetting } from './SearchableSetting' @@ -275,26 +276,15 @@ export function GitPane({ )}

- + /> ) : null, matchesSettingsSearch(searchQuery, { @@ -371,24 +361,15 @@ export function GitPane({ )}

- + /> ) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/HiddenExperimentalGroup.tsx b/src/renderer/src/components/settings/HiddenExperimentalGroup.tsx index bcf702c166a..25541870376 100644 --- a/src/renderer/src/components/settings/HiddenExperimentalGroup.tsx +++ b/src/renderer/src/components/settings/HiddenExperimentalGroup.tsx @@ -1,4 +1,5 @@ import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { translate } from '@/i18n/i18n' // Why: anything in this group is deliberately unfinished or staff-only. The @@ -38,17 +39,16 @@ export function HiddenExperimentalGroup(): React.JSX.Element { )}

- + thumbClassName="data-[state=unchecked]:bg-orange-200 dark:data-[state=unchecked]:bg-orange-100" + /> ) diff --git a/src/renderer/src/components/settings/InputPane.tsx b/src/renderer/src/components/settings/InputPane.tsx index 125e90cc003..55f64d550da 100644 --- a/src/renderer/src/components/settings/InputPane.tsx +++ b/src/renderer/src/components/settings/InputPane.tsx @@ -1,5 +1,6 @@ import type { GlobalSettings } from '../../../../shared/types' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { SearchableSetting } from './SearchableSetting' import { isDefaultPrimarySelectionMiddleClickPasteUserAgent } from '@/hooks/usePrimarySelectionPaste' import { translate } from '@/i18n/i18n' @@ -55,25 +56,18 @@ export function InputPane({ settings, updateSettings }: InputPaneProps): React.J )}

- + /> ) diff --git a/src/renderer/src/components/settings/NotificationSettingToggle.tsx b/src/renderer/src/components/settings/NotificationSettingToggle.tsx index 93502392487..7b551610114 100644 --- a/src/renderer/src/components/settings/NotificationSettingToggle.tsx +++ b/src/renderer/src/components/settings/NotificationSettingToggle.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' export type NotificationSettingToggleProps = { label: string @@ -27,22 +28,7 @@ export function NotificationSettingToggle({

{description}

- + ) } diff --git a/src/renderer/src/components/settings/PrivacyPane.test.ts b/src/renderer/src/components/settings/PrivacyPane.test.ts index 7d1cb214298..751e1b5627a 100644 --- a/src/renderer/src/components/settings/PrivacyPane.test.ts +++ b/src/renderer/src/components/settings/PrivacyPane.test.ts @@ -166,6 +166,9 @@ describe('PrivacyPane — computeBlockedReason', () => { }) describe('PrivacyPane — markup respects blocked state', () => { + const switchTag = (markup: string): string => + markup.match(/]*role="switch"[^>]*>/)?.[0] ?? '' + // The useEffect that fetches consent does not run in renderToStaticMarkup // (no DOM, no state flushes), so the rendered toggle always reads the // `consent === null` branch of the effect. Env-var blocked-state markup @@ -189,9 +192,7 @@ describe('PrivacyPane — markup respects blocked state', () => { }) }) ) - // Negative assertion: the disabled attribute should not appear on the - // switch. renderToStaticMarkup elides `disabled={false}` entirely. - expect(markup).not.toMatch(/role="switch"[^>]*disabled/) + expect(switchTag(markup)).not.toContain(' disabled=""') }) it('leaves the toggle enabled for an existing user awaiting the banner (flip dismisses the banner)', () => { @@ -206,7 +207,7 @@ describe('PrivacyPane — markup respects blocked state', () => { }) }) ) - expect(markup).not.toMatch(/role="switch"[^>]*disabled/) + expect(switchTag(markup)).not.toContain(' disabled=""') }) it('leaves the toggle enabled once the existing-user banner is resolved', () => { @@ -218,6 +219,6 @@ describe('PrivacyPane — markup respects blocked state', () => { }) }) ) - expect(markup).not.toMatch(/role="switch"[^>]*disabled/) + expect(switchTag(markup)).not.toContain(' disabled=""') }) }) diff --git a/src/renderer/src/components/settings/PrivacyPane.tsx b/src/renderer/src/components/settings/PrivacyPane.tsx index 8a096f8c2c1..f36765e4ffc 100644 --- a/src/renderer/src/components/settings/PrivacyPane.tsx +++ b/src/renderer/src/components/settings/PrivacyPane.tsx @@ -4,6 +4,7 @@ import { useMountedRef } from '@/hooks/useMountedRef' import type { GlobalSettings } from '../../../../shared/types' import type { TelemetryConsentState } from '../../../../shared/telemetry-consent-types' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { PRIVACY_URL, getConsentState, setOptIn as telemetrySetOptIn } from '../../lib/telemetry' import { useAppStore } from '../../store' import { PrivacyDiagnosticsSection } from './PrivacyDiagnosticsSection' @@ -111,26 +112,16 @@ export function PrivacyPane({ settings }: PrivacyPaneProps): React.JSX.Element { .

- + onCheckedChange={() => void handleToggle()} + /> {blocked ? : null} diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index 27bcf7acc12..703dbbfbb21 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -8,6 +8,7 @@ import { Input } from '../ui/input' import { Label } from '../ui/label' import { Popover, PopoverAnchor, PopoverContent } from '../ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { Switch } from '../ui/switch' import { Check, ChevronsUpDown, CircleX } from 'lucide-react' import { normalizeColor, type TerminalThemeOption } from '@/lib/terminal-theme' import { MAX_THEME_RESULTS } from './SettingsConstants' @@ -36,26 +37,13 @@ export function SettingsSwitch({ disabled }: SettingsSwitchProps): React.JSX.Element { return ( - + onCheckedChange={onChange} + /> ) } diff --git a/src/renderer/src/components/settings/TerminalWindowSection.tsx b/src/renderer/src/components/settings/TerminalWindowSection.tsx index 3ca33d25c39..0c38f5c00b6 100644 --- a/src/renderer/src/components/settings/TerminalWindowSection.tsx +++ b/src/renderer/src/components/settings/TerminalWindowSection.tsx @@ -3,6 +3,7 @@ import { RotateCw } from 'lucide-react' import type { GlobalSettings } from '../../../../shared/types' import { Button } from '../ui/button' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { ColorField, NumberField } from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' import { clampNumber } from '@/lib/terminal-theme' @@ -119,24 +120,14 @@ export function TerminalWindowSection({ )}

- + updateSettings({ windowBackgroundBlur: checked })} + /> {blurPendingRestart ? ( @@ -253,28 +244,18 @@ export function TerminalWindowSection({ )} - + /> - + onCheckedChange={() => void onToggleVoiceDictation()} + className="disabled:cursor-wait disabled:opacity-70" + /> diff --git a/src/renderer/src/components/settings/WslCliRegistration.tsx b/src/renderer/src/components/settings/WslCliRegistration.tsx index 586d306846e..f33e0f6487a 100644 --- a/src/renderer/src/components/settings/WslCliRegistration.tsx +++ b/src/renderer/src/components/settings/WslCliRegistration.tsx @@ -14,6 +14,7 @@ import { DialogTitle } from '../ui/dialog' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { translate } from '@/i18n/i18n' @@ -187,21 +188,16 @@ export function WslCliRegistration({ - + onCheckedChange={() => setDialogOpen(true)} + className="disabled:opacity-60" + /> diff --git a/src/renderer/src/components/sidebar/FilterToggleRow.tsx b/src/renderer/src/components/sidebar/FilterToggleRow.tsx index bcb47550649..fc1b4a7dec0 100644 --- a/src/renderer/src/components/sidebar/FilterToggleRow.tsx +++ b/src/renderer/src/components/sidebar/FilterToggleRow.tsx @@ -1,5 +1,6 @@ import React from 'react' import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu' +import { SwitchIndicator } from '@/components/ui/switch' import { cn } from '@/lib/utils' /** @@ -51,20 +52,11 @@ export function FilterToggleRow({
{shortcutLabel ? {shortcutLabel} : null} - - - + ) diff --git a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx index d99f5e860e9..994cd4e505b 100644 --- a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx +++ b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx @@ -11,6 +11,7 @@ import { DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { SwitchIndicator } from '@/components/ui/switch' import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import { getAllWorktreesFromState } from '@/store/selectors' @@ -241,20 +242,7 @@ export function HostRemoveDialog({ onClick={() => setDeleteWorkspaces((v) => !v)} className="group mt-0.5 flex shrink-0 cursor-pointer items-center rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > - - - + {deleteOptionLabel} diff --git a/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx b/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx index 59c378cc8c3..d6bb0989a76 100644 --- a/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx +++ b/src/renderer/src/components/stats/ClaudeUsageLoadingState.tsx @@ -1,4 +1,5 @@ import { RefreshCw } from 'lucide-react' +import { SwitchIndicator } from '../ui/switch' type ClaudeUsageLoadingStateProps = { title?: string @@ -20,9 +21,11 @@ export function ClaudeUsageLoadingState({
-
- -
+
diff --git a/src/renderer/src/components/stats/ClaudeUsagePane.tsx b/src/renderer/src/components/stats/ClaudeUsagePane.tsx index dfd83b6663c..8b24f0aca39 100644 --- a/src/renderer/src/components/stats/ClaudeUsagePane.tsx +++ b/src/renderer/src/components/stats/ClaudeUsagePane.tsx @@ -13,6 +13,7 @@ import { import type { ClaudeUsageRange, ClaudeUsageScope } from '../../../../shared/claude-usage-types' import { useAppStore } from '../../store' import { Button } from '../ui/button' +import { Switch } from '../ui/switch' import { DropdownMenu, DropdownMenuContent, @@ -103,19 +104,14 @@ export function ClaudeUsagePane(): React.JSX.Element { )}

- + onCheckedChange={handleSetEnabled} + /> ) @@ -222,19 +218,14 @@ export function ClaudeUsagePane(): React.JSX.Element { - + onCheckedChange={handleSetEnabled} + /> diff --git a/src/renderer/src/components/stats/CodexUsagePane.tsx b/src/renderer/src/components/stats/CodexUsagePane.tsx index 4ae5074de51..ad6e02c7b56 100644 --- a/src/renderer/src/components/stats/CodexUsagePane.tsx +++ b/src/renderer/src/components/stats/CodexUsagePane.tsx @@ -12,6 +12,7 @@ import { import type { CodexUsageRange, CodexUsageScope } from '../../../../shared/codex-usage-types' import { useAppStore } from '../../store' import { Button } from '../ui/button' +import { Switch } from '../ui/switch' import { DropdownMenu, DropdownMenuContent, @@ -99,19 +100,14 @@ export function CodexUsagePane(): React.JSX.Element { )}

- + onCheckedChange={handleSetEnabled} + /> ) @@ -224,19 +220,14 @@ export function CodexUsagePane(): React.JSX.Element { - + onCheckedChange={handleSetEnabled} + /> diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx index b70b065934e..6c11240a866 100644 --- a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -15,6 +15,7 @@ import type { } from '../../../../shared/opencode-usage-types' import { useAppStore } from '../../store' import { Button } from '../ui/button' +import { Switch } from '../ui/switch' import { DropdownMenu, DropdownMenuContent, @@ -107,19 +108,14 @@ export function OpenCodeUsagePane(): React.JSX.Element { )}

- + onCheckedChange={handleSetEnabled} + /> ) @@ -235,19 +231,14 @@ export function OpenCodeUsagePane(): React.JSX.Element { - + onCheckedChange={handleSetEnabled} + /> diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx index 0ddd6222465..4450562729e 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx @@ -1,4 +1,5 @@ import { translate } from '@/i18n/i18n' +import { Switch } from '@/components/ui/switch' type TerminalQuickCommandAppendEnterSwitchProps = { appendEnter: boolean onToggle: () => void @@ -24,25 +25,14 @@ export function TerminalQuickCommandAppendEnterSwitch({ )} - + onCheckedChange={onToggle} + /> ) } diff --git a/src/renderer/src/components/toggle-switch-handle-alignment.test.ts b/src/renderer/src/components/toggle-switch-handle-alignment.test.ts deleted file mode 100644 index b0a5574dd27..00000000000 --- a/src/renderer/src/components/toggle-switch-handle-alignment.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { readdirSync, readFileSync, statSync } from 'node:fs' -import { relative, resolve } from 'node:path' -// TypeScript 7 is a native CLI; AST tests still need the legacy JavaScript API. -import ts from 'typescript-api' -import { describe, expect, it } from 'vitest' - -// Why: the h-5 w-9 switch is hand-rolled at every call site instead of living in a -// primitive, so the handle offset drifts. The 1px-bordered track leaves a 34px content -// box for the 14px (`size-3.5`) handle, and `translate-x-0.5` insets the off state by -// 2px, so a symmetric on state is 34 - 14 - 2 = 18px (`translate-x-4.5`). - -const RENDERER_ROOT = resolve('src/renderer/src') - -const OFF_STATE = 'translate-x-0.5' -const ON_STATE = 'translate-x-4.5' - -/** The track geometry the arithmetic above is derived from; other switch sizes are not ours to check. */ -const TRACK = ['h-5', 'w-9'] - -/** The handle's 14px size, in both the shorthand and long-hand spellings in use. */ -const HANDLE_SIZE = [['size-3.5'], ['h-3.5', 'w-3.5']] - -/** Every current handle is the round knob; this is what separates it from a size-3.5 icon in the same track. */ -const HANDLE_SHAPE = 'rounded-full' - -/** Any horizontal translate — negative, arbitrary, variant-prefixed or named — so no respelling reads as "no offset". */ -const OFFSET = /(?:^|:)-?translate-x-\S+$/ - -/** Anything that moves the track off the 34px content box the 18px on-state is derived from. */ -const BORDER_WIDTH = /^border(?:-[xytrbles])?-(?:\d+|\[)/ -const TRACK_PADDING = /^p[xytrbles]?-(?:\d|\[)/ - -function collectSourceFiles(dir: string, files: string[] = []): string[] { - for (const name of readdirSync(dir)) { - const filePath = resolve(dir, name) - if (statSync(filePath).isDirectory()) { - collectSourceFiles(filePath, files) - } else if (name.endsWith('.tsx') && !name.endsWith('.test.tsx')) { - files.push(filePath) - } - } - return files -} - -/** Class tokens anywhere under a node, flattening the ternaries, template holes and `cn()` args a className is built from. */ -function tokensIn(node: ts.Node): string[] { - const tokens: string[] = [] - const read = (child: ts.Node): void => { - if (ts.isStringLiteral(child) || ts.isTemplateLiteralToken(child)) { - tokens.push(...child.text.split(/\s+/).filter(Boolean)) - } - ts.forEachChild(child, read) - } - read(node) - return tokens -} - -/** Reading the className attribute's own subtree keeps a child element's classes out of its parent's token set. */ -function classNameOf(element: ts.JsxOpeningLikeElement): ts.Node | undefined { - const className = element.attributes.properties.find( - (property): property is ts.JsxAttribute => - ts.isJsxAttribute(property) && property.name.getText() === 'className' - ) - return className?.initializer -} - -function hasAll(tokens: string[], required: string[]): boolean { - return required.every((token) => tokens.includes(token)) -} - -/** - * The offsets each side of a conditional className contributes. A handle that toggles is - * two-state by construction, so this tells a deliberately single-state knob apart from one - * that lost a branch — which a flat token list cannot. - */ -function offsetBranches(node: ts.Node): [string[], string[]][] { - const branches: [string[], string[]][] = [] - const read = (child: ts.Node): void => { - if (ts.isConditionalExpression(child)) { - const whenTrue = tokensIn(child.whenTrue).filter((token) => OFFSET.test(token)) - const whenFalse = tokensIn(child.whenFalse).filter((token) => OFFSET.test(token)) - if (whenTrue.length > 0 || whenFalse.length > 0) { - branches.push([whenTrue, whenFalse]) - return - } - } - ts.forEachChild(child, read) - } - read(node) - return branches -} - -function trackDrift(tokens: string[]): string | undefined { - if (!tokens.includes('border')) { - return 'no 1px border' - } - if (tokens.includes('box-content')) { - return 'box-content' - } - return tokens.find((token) => BORDER_WIDTH.test(token) || TRACK_PADDING.test(token)) -} - -type Handle = { at: string; offsets: string[] } -type Toggle = { at: string; branches: [string[], string[]] } -type Scan = { handles: Handle[]; toggles: Toggle[]; drifted: string[]; knobless: string[] } - -/** - * Handles are found *inside* a track element rather than by scanning source text forward, so - * neither a sibling element nor a same-file icon can be mistaken for the knob. - */ -function collectFile(filePath: string, source: string, scan: Scan): void { - const sourceFile = ts.createSourceFile( - filePath, - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX - ) - - const at = (node: ts.Node): string => { - const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) - return `${relative(RENDERER_ROOT, filePath)}:${line + 1}` - } - - const readHandles = (node: ts.Node): void => { - if (ts.isJsxOpeningLikeElement(node)) { - const className = classNameOf(node) - const tokens = className ? tokensIn(className) : [] - if ( - className && - HANDLE_SIZE.some((size) => hasAll(tokens, size)) && - tokens.includes(HANDLE_SHAPE) - ) { - scan.handles.push({ at: at(node), offsets: tokens.filter((token) => OFFSET.test(token)) }) - for (const branches of offsetBranches(className)) { - scan.toggles.push({ at: at(node), branches }) - } - } - } - ts.forEachChild(node, readHandles) - } - - const visit = (node: ts.Node): void => { - if (ts.isJsxOpeningLikeElement(node)) { - const className = classNameOf(node) - const tokens = className ? tokensIn(className) : [] - if (hasAll(tokens, TRACK)) { - const drift = trackDrift(tokens) - if (drift) { - scan.drifted.push(`${at(node)}: ${drift}`) - } - // The knob lives in the track's subtree; a self-closing track has no subtree to walk. - const found = scan.handles.length - if (ts.isJsxOpeningElement(node)) { - readHandles(node.parent) - } - // A track whose knob moved into a child component would otherwise leave the scan silently. - if (scan.handles.length === found) { - scan.knobless.push(at(node)) - } - return - } - } - ts.forEachChild(node, visit) - } - - visit(sourceFile) -} - -let cached: Scan | undefined - -/** Parsing every gated file is the expensive part, so all three assertions share one scan. */ -function scanRenderer(): Scan { - if (!cached) { - const scan: Scan = { handles: [], toggles: [], drifted: [], knobless: [] } - - for (const filePath of collectSourceFiles(RENDERER_ROOT)) { - const source = readFileSync(filePath, 'utf8') - if (TRACK.every((token) => source.includes(token))) { - collectFile(filePath, source, scan) - } - } - - // A track nested inside another h-5 w-9 element would otherwise be collected twice. - const seen = new Set() - scan.handles = scan.handles.filter((handle) => !seen.has(handle.at) && seen.add(handle.at)) - cached = scan - } - - // A scan that stops finding switches has lost its bounds, not proven the tree clean. The - // floor only has to catch a collapse — `knobless` and `drifted` catch the structural cases — - // so it sits well under the ~34 handles in the tree rather than pinning the current count. - expect(cached.handles.length, 'the switch scan lost its bounds').toBeGreaterThan(25) - - return cached -} - -describe('toggle switch handle alignment', () => { - it('insets the handle by 2px at both ends of the track', () => { - const offenders = scanRenderer().handles.flatMap((handle) => - handle.offsets.length === 0 - ? [`${handle.at}: no translate-x offset`] - : handle.offsets - .filter((offset) => offset !== OFF_STATE && offset !== ON_STATE) - .map((offset) => `${handle.at}: ${offset}`) - ) - - expect(offenders).toEqual([]) - }) - - it('gives every toggling handle both ends of the pair', () => { - const pairs = (on: string[], off: string[]): boolean => - on.length === 1 && on[0] === ON_STATE && off.length === 1 && off[0] === OFF_STATE - - const offenders = scanRenderer() - .toggles.filter(({ branches: [a, b] }) => !pairs(a, b) && !pairs(b, a)) - .map( - ({ at, branches }) => `${at}: ${branches.map((side) => side.join(' ') || '—').join(' / ')}` - ) - - expect(offenders).toEqual([]) - }) - - it('keeps every track on the geometry the 18px on-state is derived from', () => { - expect(scanRenderer().drifted).toEqual([]) - }) - - it('keeps every track a knob the scan can see', () => { - expect(scanRenderer().knobless).toEqual([]) - }) -}) diff --git a/src/renderer/src/components/ui/switch.test.tsx b/src/renderer/src/components/ui/switch.test.tsx new file mode 100644 index 00000000000..dacaf8b8699 --- /dev/null +++ b/src/renderer/src/components/ui/switch.test.tsx @@ -0,0 +1,30 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { Switch, SwitchIndicator } from './switch' + +describe('Switch', () => { + it('renders the checked state with symmetric track geometry', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('role="switch"') + expect(html).toContain('aria-checked="true"') + expect(html).toContain('p-0.5') + expect(html).toContain('data-[state=checked]:translate-x-4') + }) + + it('renders a visual-only indicator without switch semantics', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('aria-hidden="true"') + expect(html).toContain('data-state="unchecked"') + expect(html).not.toContain('role="switch"') + }) + + it('renders compact indicator geometry', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('h-3.5 w-6') + expect(html).toContain('size-2.5') + expect(html).toContain('data-[state=checked]:translate-x-2.5') + }) +}) diff --git a/src/renderer/src/components/ui/switch.tsx b/src/renderer/src/components/ui/switch.tsx new file mode 100644 index 00000000000..5213b703e73 --- /dev/null +++ b/src/renderer/src/components/ui/switch.tsx @@ -0,0 +1,67 @@ +import * as React from 'react' +import { Switch as SwitchPrimitive } from 'radix-ui' + +import { cn } from '@/lib/utils' + +const trackClassName = + 'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent p-0.5 transition-colors data-[state=checked]:bg-foreground data-[state=unchecked]:bg-muted-foreground/30' +const thumbClassName = + 'pointer-events-none block size-3.5 translate-x-0 rounded-full bg-background shadow-sm transition-transform data-[state=checked]:translate-x-4' +const compactTrackClassName = 'h-3.5 w-6 border-0 p-0.5' +const compactThumbClassName = 'size-2.5 data-[state=checked]:translate-x-2.5' + +type SwitchProps = React.ComponentProps & { + thumbClassName?: string +} + +function Switch({ className, thumbClassName: thumbClasses, ...props }: SwitchProps) { + return ( + + + + ) +} + +type SwitchIndicatorProps = Omit, 'children'> & { + checked: boolean + size?: 'default' | 'compact' + thumbClassName?: string +} + +function SwitchIndicator({ + checked, + className, + size = 'default', + thumbClassName: thumbClasses, + ...props +}: SwitchIndicatorProps) { + const state = checked ? 'checked' : 'unchecked' + return ( +